Updated on 2026-08-14
This commit is contained in:
parent
9bd372d0af
commit
5ec38a0d61
23 changed files with 424 additions and 162 deletions
|
|
@ -1,15 +1,20 @@
|
|||
package com.tangem.tap.data
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.visa.model.VisaAuthTokens
|
||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.text.encodeToByteArray
|
||||
|
||||
private const val DEFAULT_KEY = "tangem_pay_default_key"
|
||||
private const val DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY = "tangem_pay_default_customer_wallet_address_key"
|
||||
|
||||
@Singleton
|
||||
internal class DefaultTangemPayStorage @Inject constructor(
|
||||
|
|
@ -24,16 +29,49 @@ internal class DefaultTangemPayStorage @Inject constructor(
|
|||
name = "tangem_pay_storage",
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun store(authHeader: String) = withContext(dispatcherProvider.io) {
|
||||
secureStorage.store(authHeader.encodeToByteArray(throwOnInvalidSequence = true), DEFAULT_KEY)
|
||||
private val moshi by lazy {
|
||||
Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun get(): String? = withContext(dispatcherProvider.io) {
|
||||
secureStorage.get(DEFAULT_KEY)?.decodeToString(throwOnInvalidSequence = true)
|
||||
private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) }
|
||||
|
||||
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val json = tokensAdapter.toJson(tokens)
|
||||
|
||||
secureStorage.store(
|
||||
json.encodeToByteArray(throwOnInvalidSequence = true),
|
||||
createKey(customerWalletAddress),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? =
|
||||
withContext(dispatcherProvider.io) {
|
||||
secureStorage.get(createKey(customerWalletAddress))
|
||||
?.decodeToString(throwOnInvalidSequence = true)
|
||||
?.let(tokensAdapter::fromJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the only customer wallet address, since for the f&f user can issue only one card tied to one address
|
||||
*/
|
||||
override suspend fun storeCustomerWalletAddress(customerWalletAddress: String) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
secureStorage.store(
|
||||
customerWalletAddress.encodeToByteArray(throwOnInvalidSequence = true),
|
||||
DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getCustomerWalletAddress(): String? = withContext(dispatcherProvider.io) {
|
||||
secureStorage.get(DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY)?.decodeToString(throwOnInvalidSequence = true)
|
||||
}
|
||||
|
||||
override suspend fun clear() = withContext(dispatcherProvider.io) {
|
||||
secureStorage.delete(DEFAULT_KEY)
|
||||
override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
|
||||
secureStorage.delete(createKey(customerWalletAddress))
|
||||
}
|
||||
|
||||
private fun createKey(cardId: String): String = "${DEFAULT_KEY}_$cardId"
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import retrofit2.http.Header
|
|||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
interface TangemPayApi {
|
||||
|
||||
// region: auth
|
||||
|
|
@ -32,6 +33,11 @@ interface TangemPayApi {
|
|||
@POST("v1/auth/token")
|
||||
suspend fun getTokenByCustomerWallet(@Body request: GetTokenByCustomerWalletRequest): ApiResponse<JWTResponse>
|
||||
|
||||
@POST("v1/auth/token/refresh")
|
||||
suspend fun refreshCustomerWalletAccessToken(
|
||||
@Body request: RefreshCustomerWalletAccessTokenRequest,
|
||||
): ApiResponse<JWTResponse>
|
||||
|
||||
@POST("v1/auth/token")
|
||||
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse<JWTResponse>
|
||||
|
||||
|
|
@ -106,4 +112,7 @@ interface TangemPayApi {
|
|||
|
||||
@GET("v1/customer/me")
|
||||
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
|
||||
|
||||
@POST("v1/deeplink/validate")
|
||||
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.datasource.api.pay.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeeplinkValidityRequest(
|
||||
@Json(name = "link") val link: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.datasource.api.pay.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RefreshCustomerWalletAccessTokenRequest(
|
||||
@Json(name = "auth_type") val authType: String = "customer_wallet",
|
||||
@Json(name = "refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
|
@ -13,9 +13,9 @@ data class CustomerMeResponse(
|
|||
@Json(name = "id") val id: String,
|
||||
@Json(name = "state") val state: String,
|
||||
@Json(name = "createdAt") val createdAt: String,
|
||||
@Json(name = "product_instance") val productInstance: ProductInstance,
|
||||
@Json(name = "payment_account") val paymentAccount: PaymentAccount,
|
||||
@Json(name = "kyc") val kyc: Kyc,
|
||||
@Json(name = "product_instance") val productInstance: ProductInstance?,
|
||||
@Json(name = "payment_account") val paymentAccount: PaymentAccount?,
|
||||
@Json(name = "kyc") val kyc: Kyc?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.datasource.api.pay.models.response
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeeplinkValidityResponse(
|
||||
@Json(name = "result") val result: Result?,
|
||||
@Json(name = "error") val error: String?,
|
||||
) {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Result(
|
||||
@Json(name = "status") val status: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
package com.tangem.datasource.local.visa
|
||||
|
||||
import com.tangem.domain.visa.model.VisaAuthTokens
|
||||
|
||||
interface TangemPayStorage {
|
||||
|
||||
suspend fun store(authHeader: String)
|
||||
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens)
|
||||
|
||||
suspend fun get(): String?
|
||||
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
|
||||
|
||||
suspend fun clear()
|
||||
suspend fun storeCustomerWalletAddress(customerWalletAddress: String)
|
||||
|
||||
suspend fun getCustomerWalletAddress(): String?
|
||||
|
||||
suspend fun clear(customerWalletAddress: String)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
|
|||
import com.tangem.domain.visa.model.VisaDataForApprove
|
||||
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
|
||||
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
|
||||
import com.tangem.domain.visa.model.VisaAuthTokens
|
||||
import com.tangem.sdk.api.TangemSdkManager
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -15,26 +16,35 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
|
|||
private val tangemSdkManager: TangemSdkManager,
|
||||
) : TangemPayAuthDataSource {
|
||||
|
||||
override suspend fun generateNewAuthHeader(address: String, cardId: String): Either<Throwable, String> = either {
|
||||
val challenge = visaAuthRemoteDataSource
|
||||
.getCustomerWalletAuthChallenge(address)
|
||||
.mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") }
|
||||
.bind()
|
||||
override suspend fun generateNewAuthTokens(address: String, cardId: String): Either<Throwable, VisaAuthTokens> =
|
||||
either {
|
||||
val challenge = visaAuthRemoteDataSource
|
||||
.getCustomerWalletAuthChallenge(address)
|
||||
.mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") }
|
||||
.bind()
|
||||
|
||||
val signed = tangemSdkManager.visaCustomerWalletApprove(
|
||||
VisaDataForApprove(
|
||||
customerWalletCardId = cardId,
|
||||
targetAddress = address,
|
||||
dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge),
|
||||
),
|
||||
).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind()
|
||||
val signed = tangemSdkManager.visaCustomerWalletApprove(
|
||||
VisaDataForApprove(
|
||||
customerWalletCardId = cardId,
|
||||
targetAddress = address,
|
||||
dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge),
|
||||
),
|
||||
).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind()
|
||||
|
||||
visaAuthRemoteDataSource.getTokenWithCustomerWallet(
|
||||
sessionId = challenge.session.sessionId,
|
||||
signature = signed.signature,
|
||||
nonce = signed.dataToSign.hashToSign,
|
||||
visaAuthRemoteDataSource.getTokenWithCustomerWallet(
|
||||
sessionId = challenge.session.sessionId,
|
||||
signature = signed.signature,
|
||||
nonce = signed.dataToSign.hashToSign,
|
||||
)
|
||||
.mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") }
|
||||
.bind()
|
||||
}
|
||||
|
||||
override suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens> = either {
|
||||
visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(
|
||||
VisaAuthTokens.RefreshToken(refreshToken, authType = VisaAuthTokens.RefreshToken.Type.CardWallet),
|
||||
)
|
||||
.mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") }
|
||||
.mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") }
|
||||
.bind()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package com.tangem.data.pay.di
|
||||
|
||||
import com.tangem.data.pay.repository.DefaultKycRepository
|
||||
import com.tangem.data.pay.usecase.DefaultKycStartInfoUseCase
|
||||
import com.tangem.data.pay.repository.DefaultOnboardingRepository
|
||||
import com.tangem.domain.pay.repository.KycRepository
|
||||
import com.tangem.domain.pay.usecase.KycStartInfoUseCase
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -20,5 +20,5 @@ internal interface TangemPayDataModule {
|
|||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindKycStartInfoUseCase(useCase: DefaultKycStartInfoUseCase): KycStartInfoUseCase
|
||||
fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository
|
||||
}
|
||||
|
|
@ -1,67 +1,24 @@
|
|||
package com.tangem.data.pay.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.pay.KycStartInfo
|
||||
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
|
||||
import com.tangem.domain.pay.repository.KycRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class DefaultKycRepository @Inject constructor(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
internal class DefaultKycRepository @Inject constructor(
|
||||
private val tangemPayApi: TangemPayApi,
|
||||
private val authDataSource: TangemPayAuthDataSource,
|
||||
private val tangemPayStorage: TangemPayStorage,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val requestHelper: TangemPayRequestPerformer,
|
||||
) : KycRepository {
|
||||
|
||||
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
|
||||
|
||||
override suspend fun getKycStartInfo(address: String, cardId: String): Either<UniversalError, KycStartInfo> {
|
||||
val authHeader = authDataSource.generateNewAuthHeader(address, cardId)
|
||||
.getOrNull()
|
||||
.takeIf { !it.isNullOrEmpty() }
|
||||
?: return Either.Left(VisaApiError.UnknownWithoutCode)
|
||||
tangemPayStorage.store(authHeader)
|
||||
return getKycInfo(authHeader)
|
||||
}
|
||||
|
||||
override suspend fun getKycStartInfo(authHeader: String): Either<UniversalError, KycStartInfo> {
|
||||
return getKycInfo(authHeader)
|
||||
}
|
||||
|
||||
private suspend fun getKycInfo(authHeader: String): Either<UniversalError, KycStartInfo> {
|
||||
return request {
|
||||
override suspend fun getKycStartInfo() = withContext(dispatchers.io) {
|
||||
requestHelper.request { authHeader ->
|
||||
tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result
|
||||
}.map {
|
||||
KycStartInfo(token = it.token, locale = it.locale)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T : Any> request(requestBlock: suspend () -> T): Either<VisaApiError, T> {
|
||||
return runCatching {
|
||||
Either.Right(requestBlock())
|
||||
}.getOrElse { responseError ->
|
||||
if (responseError is ApiResponseError.HttpException &&
|
||||
responseError.errorBody != null
|
||||
) {
|
||||
return runCatching {
|
||||
visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode
|
||||
}.map {
|
||||
Either.Left(VisaApiError.fromBackendError(it))
|
||||
}.getOrElse {
|
||||
Either.Left(VisaApiError.UnknownWithoutCode)
|
||||
}
|
||||
}
|
||||
|
||||
return Either.Left(VisaApiError.UnknownWithoutCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.data.pay.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.ProductInstance
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val VALID_STATUS = "valid"
|
||||
|
||||
internal class DefaultOnboardingRepository @Inject constructor(
|
||||
private val tangemPayApi: TangemPayApi,
|
||||
private val requestHelper: TangemPayRequestPerformer,
|
||||
) : OnboardingRepository {
|
||||
|
||||
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> = either {
|
||||
return requestHelper.request {
|
||||
tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)).getOrThrow().result
|
||||
?: raise(VisaApiError.UnknownWithoutCode)
|
||||
}.map { result -> result.status == VALID_STATUS }
|
||||
}
|
||||
|
||||
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> = either {
|
||||
return requestHelper.request { authHeader ->
|
||||
val response = tangemPayApi.getCustomerMe(authHeader).getOrThrow()
|
||||
response.result ?: raise(VisaApiError.UnknownWithoutCode)
|
||||
}.map { result ->
|
||||
CustomerInfo(
|
||||
productInstance = result.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
|
||||
kycStatus = result.kyc?.status,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.tangem.data.pay.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.core.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
|
||||
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.domain.visa.model.VisaAuthTokens
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* For TangemPay Customer Wallet auth we are using polygon address
|
||||
*/
|
||||
private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token"
|
||||
|
||||
internal class TangemPayRequestPerformer @Inject constructor(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val tangemPayStorage: TangemPayStorage,
|
||||
private val userWalletsRepository: UserWalletsListRepository,
|
||||
private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase,
|
||||
private val authDataSource: TangemPayAuthDataSource,
|
||||
) {
|
||||
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
|
||||
|
||||
private var customerWalletAddress: String? = null
|
||||
|
||||
private val refreshTokensMutex = Mutex()
|
||||
private var refreshTokensJob: Deferred<Either<UniversalError, VisaAuthTokens>>? = null
|
||||
|
||||
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> T): Either<UniversalError, T> = either {
|
||||
withContext(dispatchers.io) {
|
||||
performRequest(requestBlock = requestBlock, refreshTokens = ::refreshAuthTokens).bind()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T : Any> performRequest(
|
||||
requestBlock: suspend (header: String) -> T,
|
||||
refreshTokens: (suspend () -> Either<UniversalError, VisaAuthTokens>)? = null,
|
||||
): Either<UniversalError, T> = either {
|
||||
runCatching {
|
||||
requestBlock("Bearer ${getAccessTokens().bind().accessToken}")
|
||||
}.getOrElse { error ->
|
||||
when (error) {
|
||||
is ApiResponseError.HttpException -> {
|
||||
if (refreshTokens != null && error.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) {
|
||||
refreshOrJoin(refreshTokens).bind()
|
||||
performRequest(requestBlock, refreshTokens = null).bind()
|
||||
} else {
|
||||
raise(mapHttpError(error))
|
||||
}
|
||||
}
|
||||
else -> raise(VisaApiError.UnknownWithoutCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshOrJoin(
|
||||
refreshTokens: suspend () -> Either<UniversalError, VisaAuthTokens>,
|
||||
): Either<UniversalError, VisaAuthTokens> {
|
||||
val jobToAwait: Deferred<Either<UniversalError, VisaAuthTokens>> =
|
||||
refreshTokensMutex.withLock {
|
||||
val current = refreshTokensJob
|
||||
if (current == null || current.isCompleted) {
|
||||
coroutineScope {
|
||||
async { refreshTokens() }.also { refreshTokensJob = it }
|
||||
}
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
val result = try {
|
||||
jobToAwait.await()
|
||||
} catch (ignore: Throwable) {
|
||||
Either.Left(VisaApiError.UnknownWithoutCode)
|
||||
} finally {
|
||||
refreshTokensMutex.withLock {
|
||||
if (refreshTokensJob === jobToAwait && jobToAwait.isCompleted) {
|
||||
refreshTokensJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun getCustomerWalletAddress(): Either<UniversalError, String> = either {
|
||||
customerWalletAddress
|
||||
?: tangemPayStorage.getCustomerWalletAddress()
|
||||
?: fetchAuthInputData().bind().address
|
||||
}
|
||||
|
||||
private suspend fun getAccessTokens(): Either<UniversalError, VisaAuthTokens> = either {
|
||||
val address = getCustomerWalletAddress().bind()
|
||||
tangemPayStorage.getAuthTokens(address) ?: fetchTokens().bind()
|
||||
}
|
||||
|
||||
private fun mapHttpError(throwable: ApiResponseError.HttpException): UniversalError {
|
||||
val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode
|
||||
return runCatching {
|
||||
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: throwable.code.numericCode
|
||||
}.map {
|
||||
VisaApiError.fromBackendError(it)
|
||||
}.getOrElse {
|
||||
VisaApiError.UnknownWithoutCode
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAuthInputData(): Either<UniversalError, AuthInputData> = either {
|
||||
val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold
|
||||
?: raise(VisaApiError.UnknownWithoutCode)
|
||||
|
||||
val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE))
|
||||
.getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode)
|
||||
|
||||
customerWalletAddress = address
|
||||
tangemPayStorage.storeCustomerWalletAddress(address)
|
||||
|
||||
AuthInputData(address, wallet.cardId)
|
||||
}
|
||||
|
||||
private suspend fun fetchTokens(): Either<UniversalError, VisaAuthTokens> = either {
|
||||
val inputData = fetchAuthInputData().bind()
|
||||
val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId)
|
||||
.getOrNull()
|
||||
?: return Either.Left(VisaApiError.UnknownWithoutCode)
|
||||
tangemPayStorage.storeAuthTokens(inputData.address, tokens)
|
||||
tokens
|
||||
}
|
||||
|
||||
private suspend fun refreshAuthTokens(): Either<UniversalError, VisaAuthTokens> = either {
|
||||
val customerWalletAddress = getCustomerWalletAddress().bind()
|
||||
val refreshToken = getAccessTokens().bind().refreshToken.value
|
||||
val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull()
|
||||
?: raise(VisaApiError.UnknownWithoutCode)
|
||||
tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens)
|
||||
tokens
|
||||
}
|
||||
}
|
||||
|
||||
internal data class AuthInputData(
|
||||
val address: String,
|
||||
val cardId: String,
|
||||
)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
package com.tangem.data.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.domain.core.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.pay.KycStartInfo
|
||||
import com.tangem.domain.pay.repository.KycRepository
|
||||
import com.tangem.domain.pay.usecase.KycStartInfoUseCase
|
||||
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* For TangemPay Customer Wallet auth we are using polygon address
|
||||
*/
|
||||
private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token"
|
||||
|
||||
internal class DefaultKycStartInfoUseCase @Inject constructor(
|
||||
private val userWalletsRepository: UserWalletsListRepository,
|
||||
private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase,
|
||||
private val kycRepository: KycRepository,
|
||||
) : KycStartInfoUseCase {
|
||||
|
||||
override suspend fun invoke(): Either<UniversalError, KycStartInfo> = either {
|
||||
val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold
|
||||
?: raise(VisaApiError.UnknownWithoutCode)
|
||||
|
||||
val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE))
|
||||
.getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode)
|
||||
|
||||
kycRepository.getKycStartInfo(address, wallet.cardId).bind()
|
||||
}
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
|
|||
sessionId: String,
|
||||
signature: String,
|
||||
nonce: String,
|
||||
): Either<VisaApiError, String> = withContext(dispatchers.io) {
|
||||
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
|
||||
request {
|
||||
visaAuthApi.getTokenByCustomerWallet(
|
||||
GetTokenByCustomerWalletRequest(
|
||||
|
|
@ -93,7 +93,28 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
|
|||
),
|
||||
).getOrThrow()
|
||||
}.map { response ->
|
||||
"Bearer ${response.result.accessToken}"
|
||||
VisaAuthTokens(
|
||||
response.result.accessToken,
|
||||
VisaAuthTokens.RefreshToken(
|
||||
response.result.refreshToken,
|
||||
VisaAuthTokens.RefreshToken.Type.CardWallet,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshCustomerWalletAuthTokens(
|
||||
refreshToken: VisaAuthTokens.RefreshToken,
|
||||
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
|
||||
request {
|
||||
visaAuthApi.refreshCustomerWalletAccessToken(
|
||||
RefreshCustomerWalletAccessTokenRequest(refreshToken = refreshToken.value),
|
||||
).getOrThrow()
|
||||
}.map { response ->
|
||||
VisaAuthTokens(
|
||||
accessToken = response.result.accessToken,
|
||||
refreshToken = refreshToken.copy(value = response.result.refreshToken),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,24 +178,25 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either<VisaApiError, VisaAuthTokens> {
|
||||
return request {
|
||||
visaAuthApi.exchangeAccessToken(
|
||||
ExchangeAccessTokenRequest(
|
||||
accessToken = tokens.accessToken,
|
||||
refreshToken = tokens.refreshToken.value,
|
||||
),
|
||||
).getOrThrow()
|
||||
}.map { response ->
|
||||
VisaAuthTokens(
|
||||
accessToken = response.result.accessToken,
|
||||
refreshToken = VisaAuthTokens.RefreshToken(
|
||||
value = response.result.refreshToken,
|
||||
authType = VisaAuthTokens.RefreshToken.Type.CardWallet,
|
||||
),
|
||||
)
|
||||
override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either<VisaApiError, VisaAuthTokens> =
|
||||
withContext(dispatchers.io) {
|
||||
request {
|
||||
visaAuthApi.exchangeAccessToken(
|
||||
ExchangeAccessTokenRequest(
|
||||
accessToken = tokens.accessToken,
|
||||
refreshToken = tokens.refreshToken.value,
|
||||
),
|
||||
).getOrThrow()
|
||||
}.map { response ->
|
||||
VisaAuthTokens(
|
||||
accessToken = response.result.accessToken,
|
||||
refreshToken = VisaAuthTokens.RefreshToken(
|
||||
value = response.result.refreshToken,
|
||||
authType = VisaAuthTokens.RefreshToken.Type.CardWallet,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T : Any> request(requestBlock: suspend () -> T): Either<VisaApiError, T> {
|
||||
return runCatching {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package com.tangem.domain.pay.datasource
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.visa.model.VisaAuthTokens
|
||||
|
||||
interface TangemPayAuthDataSource {
|
||||
|
||||
suspend fun generateNewAuthHeader(address: String, cardId: String): Either<Throwable, String>
|
||||
suspend fun generateNewAuthTokens(address: String, cardId: String): Either<Throwable, VisaAuthTokens>
|
||||
|
||||
suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens>
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
private const val APPROVED_KYC_STATUS = "APPROVED"
|
||||
private const val ACTIVE_PI_STATUS = "active"
|
||||
|
||||
data class CustomerInfo(
|
||||
val productInstance: ProductInstance?,
|
||||
val kycStatus: String?,
|
||||
) {
|
||||
|
||||
fun isKycApproved() = kycStatus == APPROVED_KYC_STATUS
|
||||
|
||||
fun isProductInstanceActive() = productInstance?.status == ACTIVE_PI_STATUS
|
||||
}
|
||||
|
||||
data class ProductInstance(
|
||||
val id: String,
|
||||
val status: String,
|
||||
)
|
||||
|
|
@ -7,12 +7,7 @@ import com.tangem.domain.pay.KycStartInfo
|
|||
interface KycRepository {
|
||||
|
||||
/**
|
||||
* Returns fresh KYC data to start the survey. Used only for first time launch
|
||||
* Returns KYC data to start or continue the survey
|
||||
*/
|
||||
suspend fun getKycStartInfo(address: String, cardId: String): Either<UniversalError, KycStartInfo>
|
||||
|
||||
/**
|
||||
* Returns KYC data to continue the survey. Used when KYC wasn't finished by the user
|
||||
*/
|
||||
suspend fun getKycStartInfo(authHeader: String): Either<UniversalError, KycStartInfo>
|
||||
suspend fun getKycStartInfo(): Either<UniversalError, KycStartInfo>
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.domain.pay.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
|
||||
interface OnboardingRepository {
|
||||
|
||||
suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean>
|
||||
|
||||
suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo>
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.domain.pay.KycStartInfo
|
||||
|
||||
interface KycStartInfoUseCase {
|
||||
|
||||
suspend operator fun invoke(): Either<UniversalError, KycStartInfo>
|
||||
}
|
||||
|
|
@ -26,7 +26,11 @@ interface VisaAuthRemoteDataSource {
|
|||
sessionId: String,
|
||||
signature: String,
|
||||
nonce: String,
|
||||
): Either<VisaApiError, String>
|
||||
): Either<VisaApiError, VisaAuthTokens>
|
||||
|
||||
suspend fun refreshCustomerWalletAuthTokens(
|
||||
refreshToken: VisaAuthTokens.RefreshToken,
|
||||
): Either<VisaApiError, VisaAuthTokens>
|
||||
|
||||
suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either<VisaApiError, VisaAuthTokens>
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class DefaultKycComponent @AssistedInject constructor(
|
|||
.withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler)
|
||||
.withTheme(TangemSNSTheme.theme(activity))
|
||||
.withIconHandler(TangemSNSIconHandler())
|
||||
.withLocale(Locale("en"))
|
||||
.withLocale(Locale(startInfo.locale))
|
||||
.build()
|
||||
snsSdk.launch()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable
|
|||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.domain.pay.KycStartInfo
|
||||
import com.tangem.domain.pay.usecase.KycStartInfoUseCase
|
||||
import com.tangem.domain.pay.repository.KycRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -15,7 +15,7 @@ import javax.inject.Inject
|
|||
@ModelScoped
|
||||
class DefaultKycModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val kycStartInfoUseCase: KycStartInfoUseCase,
|
||||
private val kycRepository: KycRepository,
|
||||
) : Model() {
|
||||
|
||||
private val _uiState: MutableStateFlow<KycStartInfo?> = MutableStateFlow(null)
|
||||
|
|
@ -23,7 +23,7 @@ class DefaultKycModel @Inject constructor(
|
|||
|
||||
fun getKycToken() {
|
||||
modelScope.launch {
|
||||
kycStartInfoUseCase().getOrNull()?.let { _uiState.emit(it) }
|
||||
kycRepository.getKycStartInfo().getOrNull()?.let { _uiState.emit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import dagger.assisted.AssistedInject
|
|||
/**
|
||||
* Mocking it for release/external builds to exclude SumSub dependency
|
||||
*/
|
||||
class MockKycComponent @AssistedInject constructor(
|
||||
internal class MockKycComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
) : KycComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue