Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-17 11:39:17 +04:00
parent 9bd372d0af
commit 5ec38a0d61
23 changed files with 424 additions and 162 deletions

View file

@ -1,15 +1,20 @@
package com.tangem.tap.data package com.tangem.tap.data
import android.content.Context 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.datasource.local.visa.TangemPayStorage
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.text.encodeToByteArray
private const val DEFAULT_KEY = "tangem_pay_default_key" 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 @Singleton
internal class DefaultTangemPayStorage @Inject constructor( internal class DefaultTangemPayStorage @Inject constructor(
@ -24,16 +29,49 @@ internal class DefaultTangemPayStorage @Inject constructor(
name = "tangem_pay_storage", name = "tangem_pay_storage",
) )
} }
private val moshi by lazy {
override suspend fun store(authHeader: String) = withContext(dispatcherProvider.io) { Moshi.Builder()
secureStorage.store(authHeader.encodeToByteArray(throwOnInvalidSequence = true), DEFAULT_KEY) .add(KotlinJsonAdapterFactory())
.build()
} }
override suspend fun get(): String? = withContext(dispatcherProvider.io) { private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) }
secureStorage.get(DEFAULT_KEY)?.decodeToString(throwOnInvalidSequence = true)
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) { override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
secureStorage.delete(DEFAULT_KEY) secureStorage.delete(createKey(customerWalletAddress))
} }
private fun createKey(cardId: String): String = "${DEFAULT_KEY}_$cardId"
} }

View file

@ -9,6 +9,7 @@ import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
@Suppress("TooManyFunctions")
interface TangemPayApi { interface TangemPayApi {
// region: auth // region: auth
@ -32,6 +33,11 @@ interface TangemPayApi {
@POST("v1/auth/token") @POST("v1/auth/token")
suspend fun getTokenByCustomerWallet(@Body request: GetTokenByCustomerWalletRequest): ApiResponse<JWTResponse> 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") @POST("v1/auth/token")
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse<JWTResponse> suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse<JWTResponse>
@ -106,4 +112,7 @@ interface TangemPayApi {
@GET("v1/customer/me") @GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse> suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
@POST("v1/deeplink/validate")
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>
} }

View file

@ -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,
)

View file

@ -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,
)

View file

@ -13,9 +13,9 @@ data class CustomerMeResponse(
@Json(name = "id") val id: String, @Json(name = "id") val id: String,
@Json(name = "state") val state: String, @Json(name = "state") val state: String,
@Json(name = "createdAt") val createdAt: String, @Json(name = "createdAt") val createdAt: String,
@Json(name = "product_instance") val productInstance: ProductInstance, @Json(name = "product_instance") val productInstance: ProductInstance?,
@Json(name = "payment_account") val paymentAccount: PaymentAccount, @Json(name = "payment_account") val paymentAccount: PaymentAccount?,
@Json(name = "kyc") val kyc: Kyc, @Json(name = "kyc") val kyc: Kyc?,
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View file

@ -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,
)
}

View file

@ -1,10 +1,16 @@
package com.tangem.datasource.local.visa package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.VisaAuthTokens
interface TangemPayStorage { 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)
} }

View file

@ -7,6 +7,7 @@ import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject import javax.inject.Inject
@ -15,26 +16,35 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
private val tangemSdkManager: TangemSdkManager, private val tangemSdkManager: TangemSdkManager,
) : TangemPayAuthDataSource { ) : TangemPayAuthDataSource {
override suspend fun generateNewAuthHeader(address: String, cardId: String): Either<Throwable, String> = either { override suspend fun generateNewAuthTokens(address: String, cardId: String): Either<Throwable, VisaAuthTokens> =
val challenge = visaAuthRemoteDataSource either {
.getCustomerWalletAuthChallenge(address) val challenge = visaAuthRemoteDataSource
.mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") } .getCustomerWalletAuthChallenge(address)
.bind() .mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") }
.bind()
val signed = tangemSdkManager.visaCustomerWalletApprove( val signed = tangemSdkManager.visaCustomerWalletApprove(
VisaDataForApprove( VisaDataForApprove(
customerWalletCardId = cardId, customerWalletCardId = cardId,
targetAddress = address, targetAddress = address,
dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge), dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge),
), ),
).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind() ).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind()
visaAuthRemoteDataSource.getTokenWithCustomerWallet( visaAuthRemoteDataSource.getTokenWithCustomerWallet(
sessionId = challenge.session.sessionId, sessionId = challenge.session.sessionId,
signature = signed.signature, signature = signed.signature,
nonce = signed.dataToSign.hashToSign, 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() .bind()
} }
} }

View file

@ -1,9 +1,9 @@
package com.tangem.data.pay.di package com.tangem.data.pay.di
import com.tangem.data.pay.repository.DefaultKycRepository 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.repository.KycRepository
import com.tangem.domain.pay.usecase.KycStartInfoUseCase import com.tangem.domain.pay.repository.OnboardingRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -20,5 +20,5 @@ internal interface TangemPayDataModule {
@Binds @Binds
@Singleton @Singleton
fun bindKycStartInfoUseCase(useCase: DefaultKycStartInfoUseCase): KycStartInfoUseCase fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository
} }

View file

@ -1,67 +1,24 @@
package com.tangem.data.pay.repository 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.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi 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.KycStartInfo
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.repository.KycRepository 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 import javax.inject.Inject
class DefaultKycRepository @Inject constructor( internal class DefaultKycRepository @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val tangemPayApi: TangemPayApi, private val tangemPayApi: TangemPayApi,
private val authDataSource: TangemPayAuthDataSource, private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayStorage: TangemPayStorage, private val requestHelper: TangemPayRequestPerformer,
) : KycRepository { ) : KycRepository {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) override suspend fun getKycStartInfo() = withContext(dispatchers.io) {
requestHelper.request { authHeader ->
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 {
tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result
}.map { }.map {
KycStartInfo(token = it.token, locale = it.locale) 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)
}
}
} }

View file

@ -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,
)
}
}
}

View file

@ -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,
)

View file

@ -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()
}
}

View file

@ -83,7 +83,7 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
sessionId: String, sessionId: String,
signature: String, signature: String,
nonce: String, nonce: String,
): Either<VisaApiError, String> = withContext(dispatchers.io) { ): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
request { request {
visaAuthApi.getTokenByCustomerWallet( visaAuthApi.getTokenByCustomerWallet(
GetTokenByCustomerWalletRequest( GetTokenByCustomerWalletRequest(
@ -93,7 +93,28 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
), ),
).getOrThrow() ).getOrThrow()
}.map { response -> }.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> { override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either<VisaApiError, VisaAuthTokens> =
return request { withContext(dispatchers.io) {
visaAuthApi.exchangeAccessToken( request {
ExchangeAccessTokenRequest( visaAuthApi.exchangeAccessToken(
accessToken = tokens.accessToken, ExchangeAccessTokenRequest(
refreshToken = tokens.refreshToken.value, accessToken = tokens.accessToken,
), refreshToken = tokens.refreshToken.value,
).getOrThrow() ),
}.map { response -> ).getOrThrow()
VisaAuthTokens( }.map { response ->
accessToken = response.result.accessToken, VisaAuthTokens(
refreshToken = VisaAuthTokens.RefreshToken( accessToken = response.result.accessToken,
value = response.result.refreshToken, refreshToken = VisaAuthTokens.RefreshToken(
authType = VisaAuthTokens.RefreshToken.Type.CardWallet, value = response.result.refreshToken,
), authType = VisaAuthTokens.RefreshToken.Type.CardWallet,
) ),
)
}
} }
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): Either<VisaApiError, T> { private suspend fun <T : Any> request(requestBlock: suspend () -> T): Either<VisaApiError, T> {
return runCatching { return runCatching {

View file

@ -1,8 +1,11 @@
package com.tangem.domain.pay.datasource package com.tangem.domain.pay.datasource
import arrow.core.Either import arrow.core.Either
import com.tangem.domain.visa.model.VisaAuthTokens
interface TangemPayAuthDataSource { 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>
} }

View file

@ -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,
)

View file

@ -7,12 +7,7 @@ import com.tangem.domain.pay.KycStartInfo
interface KycRepository { 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> suspend fun getKycStartInfo(): 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>
} }

View file

@ -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>
}

View file

@ -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>
}

View file

@ -26,7 +26,11 @@ interface VisaAuthRemoteDataSource {
sessionId: String, sessionId: String,
signature: String, signature: String,
nonce: 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> suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either<VisaApiError, VisaAuthTokens>

View file

@ -29,7 +29,7 @@ class DefaultKycComponent @AssistedInject constructor(
.withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler) .withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler)
.withTheme(TangemSNSTheme.theme(activity)) .withTheme(TangemSNSTheme.theme(activity))
.withIconHandler(TangemSNSIconHandler()) .withIconHandler(TangemSNSIconHandler())
.withLocale(Locale("en")) .withLocale(Locale(startInfo.locale))
.build() .build()
snsSdk.launch() snsSdk.launch()
} }

View file

@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.domain.pay.KycStartInfo 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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -15,7 +15,7 @@ import javax.inject.Inject
@ModelScoped @ModelScoped
class DefaultKycModel @Inject constructor( class DefaultKycModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val kycStartInfoUseCase: KycStartInfoUseCase, private val kycRepository: KycRepository,
) : Model() { ) : Model() {
private val _uiState: MutableStateFlow<KycStartInfo?> = MutableStateFlow(null) private val _uiState: MutableStateFlow<KycStartInfo?> = MutableStateFlow(null)
@ -23,7 +23,7 @@ class DefaultKycModel @Inject constructor(
fun getKycToken() { fun getKycToken() {
modelScope.launch { modelScope.launch {
kycStartInfoUseCase().getOrNull()?.let { _uiState.emit(it) } kycRepository.getKycStartInfo().getOrNull()?.let { _uiState.emit(it) }
} }
} }
} }

View file

@ -8,7 +8,7 @@ import dagger.assisted.AssistedInject
/** /**
* Mocking it for release/external builds to exclude SumSub dependency * Mocking it for release/external builds to exclude SumSub dependency
*/ */
class MockKycComponent @AssistedInject constructor( internal class MockKycComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext, @Assisted appComponentContext: AppComponentContext,
) : KycComponent, AppComponentContext by appComponentContext { ) : KycComponent, AppComponentContext by appComponentContext {