Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-28 14:12:42 +05:00
parent ff55704d32
commit 9e2eb5710b
82 changed files with 1150 additions and 401 deletions

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.domain.visa)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
@ -31,6 +32,7 @@ dependencies {
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.quotes)
implementation(projects.domain.common)
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)

View file

@ -5,8 +5,8 @@ import arrow.core.raise.either
import com.tangem.common.CompletionResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject
@ -16,18 +16,14 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
) : TangemPayAuthDataSource {
override suspend fun produceInitialCredentials(cardId: String): Either<Throwable, TangemPayInitialCredentials> {
val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId)
return when (initialCredentials) {
return when (val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId)) {
is CompletionResult.Failure<*> -> Either.Left(initialCredentials.error)
is CompletionResult.Success<TangemPayInitialCredentials> -> Either.Right(initialCredentials.data)
}
}
override suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens> = either {
visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(
VisaAuthTokens.RefreshToken(refreshToken, authType = VisaAuthTokens.RefreshToken.Type.CardWallet),
)
override suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, TangemPayAuthTokens> = either {
visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(refreshToken = refreshToken)
.mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") }
.bind()
}

View file

@ -1,26 +1,22 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.KycStartInfo
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
private const val TAG = "TangemPay: KycRepository"
internal class DefaultKycRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
) : KycRepository {
override suspend fun getKycStartInfo(): Either<UniversalError, KycStartInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
tangemPayApi.getKycAccess(authHeader = authHeader)
}.result
KycStartInfo(token = result.token, locale = result.locale)
}
override suspend fun getKycStartInfo(userWalletId: UserWalletId): Either<VisaApiError, KycStartInfo> {
return requestHelper.performRequest(
userWalletId,
) { authHeader -> tangemPayApi.getKycAccess(authHeader = authHeader) }
.map { KycStartInfo(token = it.result.token, locale = it.result.locale) }
}
}

View file

@ -1,26 +1,29 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.TangemPayWalletsManager
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.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
private const val VALID_STATUS = "valid"
@ -34,105 +37,82 @@ internal class DefaultOnboardingRepository @Inject constructor(
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : OnboardingRepository {
// Save data for a session
private var lastFetchedCustomerInfo: CustomerInfo? = null
private val lastFetchedCustomerInfoMap = ConcurrentHashMap<UserWalletId, CustomerInfo>()
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
val result = tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
.getOrThrow()
.result
result?.status == VALID_STATUS
override suspend fun validateDeeplink(link: String): Either<VisaApiError, Boolean> {
return requestHelper.performWithStaticToken {
tangemPayApi.validateDeeplink(body = DeeplinkValidityRequest(link = link))
}.map { response ->
response.result?.status == VALID_STATUS
}
}
override suspend fun isTangemPayInitialDataProduced(): Boolean {
val walletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId
val customerWalletAddress = tangemPayStorage.getCustomerWalletAddress(walletId) ?: return false
tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return false
override suspend fun isTangemPayInitialDataProduced(userWalletId: UserWalletId): Boolean {
return withContext(dispatcherProvider.io) {
val customerWalletAddress =
tangemPayStorage.getCustomerWalletAddress(userWalletId) ?: return@withContext false
tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return@withContext false
return true
return@withContext true
}
}
override suspend fun produceInitialData() {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay()
val initialCredentials = authDataSource.produceInitialCredentials(cardId = wallet.cardId)
.fold(
ifLeft = { error -> error("Can not produce initial data: ${error.message}") },
ifRight = { it },
override suspend fun produceInitialData(userWalletId: UserWalletId) {
withContext(dispatcherProvider.io) {
val initialCredentials = authDataSource.produceInitialCredentials(cardId = getCardId(userWalletId))
.fold(
ifLeft = { error -> error("Can not produce initial data: ${error.message}") },
ifRight = { it },
)
// should storeCheckCustomerWalletResult because we already know this
tangemPayStorage.storeCheckCustomerWalletResult(userWalletId)
tangemPayStorage.storeCustomerWalletAddress(
userWalletId = userWalletId,
customerWalletAddress = initialCredentials.customerWalletAddress,
)
tangemPayStorage.storeAuthTokens(
customerWalletAddress = initialCredentials.customerWalletAddress,
tokens = initialCredentials.authTokens,
)
tangemPayStorage.storeCustomerWalletAddress(
userWalletId = wallet.walletId,
customerWalletAddress = initialCredentials.customerWalletAddress,
)
tangemPayStorage.storeAuthTokens(
customerWalletAddress = initialCredentials.customerWalletAddress,
tokens = initialCredentials.authTokens,
)
}
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
getCustomerInfo(result)
}
}
override suspend fun getMainScreenCustomerInfo(): Either<UniversalError, MainScreenCustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress()
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> {
// TODO implement selector
return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerMe(authHeader) }
.map { response -> getCustomerInfo(userWalletId = userWalletId, response = response.result) }
}
when (val orderId = tangemPayStorage.getOrderId(customerWalletAddress)) {
// If order id wasn't saved -> start order creation and get customer info
null -> {
createOrder()
MainScreenCustomerInfo(
info = getCustomerInfoWithPersistedToken(),
orderStatus = OrderStatus.UNKNOWN,
)
}
// If order id was saved -> check its status
else -> {
val orderStatus = getOrderStatus(orderId)
if (orderStatus == OrderStatus.CANCELED) {
// If order was cancelled -> start order creation
createOrder()
}
val customerInfo = when (orderStatus) {
// Kyc is passed and user waits for order creation -> no need to get customer info
OrderStatus.NEW,
OrderStatus.PROCESSING,
-> CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null)
// Order was created/cancelled -> clear order id and get customer info
OrderStatus.UNKNOWN,
OrderStatus.COMPLETED,
OrderStatus.CANCELED,
-> getCustomerInfoWithPersistedToken().also {
tangemPayStorage.clearOrderId(customerWalletAddress)
}
}
MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus)
}
}
override suspend fun clearOrderId(userWalletId: UserWalletId) {
withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.clearOrderId(customerWalletAddress = customerWalletAddress)
}
}
override fun getSavedCustomerInfo(): CustomerInfo? {
return lastFetchedCustomerInfo
override suspend fun getOrderId(userWalletId: UserWalletId): String? {
return withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.getOrderId(customerWalletAddress)
}
}
private suspend fun createOrder() = withContext(dispatcherProvider.io) {
override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? {
return lastFetchedCustomerInfoMap[userWalletId]
}
override suspend fun createOrder(userWalletId: UserWalletId) = withContext(dispatcherProvider.io) {
launch {
requestHelper.runWithErrorLogs(TAG) {
val walletAddress = requestHelper.getCustomerWalletAddress()
val result = requestHelper.request { authHeader ->
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
val result = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress))
}.result ?: error("Create order result is null")
@ -141,7 +121,23 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
private suspend fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo {
private fun getCardId(userWalletId: UserWalletId): String {
val userWallet = if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
} else {
userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == userWalletId }
} ?: error("no userWallet found")
return if (userWallet is UserWallet.Cold) {
userWallet.cardId
} else {
TODO("[REDACTED_JIRA]")
}
}
private suspend fun getCustomerInfo(
userWalletId: UserWalletId,
response: CustomerMeResponse.Result?,
): CustomerInfo {
val card = response?.card
val balance = response?.balance
val paymentAccount = response?.paymentAccount
@ -157,46 +153,59 @@ internal class DefaultOnboardingRepository @Inject constructor(
null
}
val productInstance = response?.productInstance?.let { instance ->
cardFrozenStateStore.store(
key = instance.cardId,
value = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
},
)
ProductInstance(
id = instance.id,
cardId = instance.cardId,
status = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE
else -> ProductInstance.Status.INACTIVE
},
)
val cardFrozenState = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
}
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState)
}
return CustomerInfo(
productInstance = productInstance,
isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS,
cardInfo = cardInfo,
).also { lastFetchedCustomerInfo = it }
}
private suspend fun getOrderStatus(orderId: String): OrderStatus {
val result = requestHelper.request { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.result ?: error("Order result is null")
return when (result.status) {
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
).also {
lastFetchedCustomerInfoMap[userWalletId] = it
}
}
private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo {
val result = requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
return getCustomerInfo(result)
override suspend fun getOrderStatus(
userWalletId: UserWalletId,
orderId: String,
): Either<VisaApiError, OrderStatus> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
}.map { response ->
when (response.result?.status) {
null -> OrderStatus.UNKNOWN
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
}
}
override suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
val hasTangemPay = tangemPayStorage.checkCustomerWalletResult(userWalletId)
if (hasTangemPay == true) {
return Either.Right(true)
}
return requestHelper.performWithStaticToken { staticToken ->
tangemPayApi.checkCustomerWalletId(
authHeader = staticToken,
customerWalletId = userWalletId.stringValue,
)
}.map { response ->
val id = response.id
if (!id.isNullOrEmpty()) {
tangemPayStorage.storeCheckCustomerWalletResult(userWalletId = userWalletId)
true
} else {
false
}
}
}
}

View file

@ -14,6 +14,7 @@ import com.tangem.datasource.api.pay.models.request.SetPinRequest
import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
@ -35,9 +36,9 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
) : TangemPayCardDetailsRepository {
override suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance> {
override suspend fun getCardBalance(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardBalance> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
val result = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.getCardBalance(authHeader)
}.result ?: error("Cannot get card balance")
TangemPayCardBalance(
@ -47,12 +48,12 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
override suspend fun revealCardDetails(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardDetails> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val result = requestHelper.request { authHeader ->
val result = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.revealCardDetails(
authHeader = authHeader,
body = CardDetailsRequest(sessionId = sessionId),
@ -81,14 +82,14 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun setPin(pin: String): Either<UniversalError, SetPinResult> {
override suspend fun setPin(userWalletId: UserWalletId, pin: String): Either<UniversalError, SetPinResult> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes)
secretKeyBytes.fill(0)
val status = requestHelper.request { authHeader ->
val status = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.setPin(
authHeader = authHeader,
body = SetPinRequest(
@ -107,21 +108,24 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun isAddToWalletDone(): Either<UniversalError, Boolean> {
override suspend fun isAddToWalletDone(userWalletId: UserWalletId): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress())
storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress(userWalletId))
}
}
override suspend fun setAddToWalletAsDone(): Either<UniversalError, Unit> {
override suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either<UniversalError, Unit> {
return requestHelper.runWithErrorLogs(TAG) {
storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(), isDone = true)
storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(userWalletId), isDone = true)
}
}
override suspend fun freezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState> {
override suspend fun freezeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardFrozenState> {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending)
return requestHelper.makeSafeRequest {
return requestHelper.makeSafeRequest(userWalletId) {
tangemPayApi.freezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId))
}.onLeft {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Unfrozen)
@ -141,9 +145,12 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun unfreezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState> {
override suspend fun unfreezeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardFrozenState> {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending)
return requestHelper.makeSafeRequest {
return requestHelper.makeSafeRequest(userWalletId) {
tangemPayApi.unfreezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId))
}.onLeft {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Frozen)

View file

@ -6,6 +6,7 @@ import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
@ -34,6 +35,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
private val txHistoryItemConverter by lazy { TangemPayTxHistoryItemConverter(moshi) }
override fun getTxHistoryBatchFlow(
userWalletId: UserWalletId,
batchSize: Int,
context: TangemPayTxHistoryListBatchingContext,
): TangemPayTxHistoryListBatchFlow {
@ -41,18 +43,24 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 },
batchFetcher = createFetcher(batchSize),
batchFetcher = createFetcher(userWalletId, batchSize),
).toBatchFlow()
}
private fun createFetcher(
userWalletId: UserWalletId,
batchSize: Int,
): BatchFetcher<TangemPayTxHistoryListConfig, List<TangemPayTxHistoryItem>> {
return CursorBatchFetcher(
prefetchDistance = batchSize,
batchSize = batchSize,
subFetcher = { request, _, _ ->
val items = loadItems(config = request.params, cursor = request.cursor, limit = request.limit)
val items = loadItems(
userWalletId = userWalletId,
config = request.params,
cursor = request.cursor,
limit = request.limit,
)
BatchFetchResult.Success(
data = items,
last = items.size < request.limit,
@ -64,6 +72,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
}
private suspend fun loadItems(
userWalletId: UserWalletId,
config: TangemPayTxHistoryListConfig,
cursor: String?,
limit: Int,
@ -71,7 +80,14 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
cacheRegistry.invokeOnExpire(
key = getCacheKey(customerWalletAddress = config.customerWalletAddress, cursor = cursor),
skipCache = config.shouldRefresh,
block = { fetch(customerWalletAddress = config.customerWalletAddress, cursor = cursor, pageSize = limit) },
block = {
fetch(
userWalletId = userWalletId,
customerWalletAddress = config.customerWalletAddress,
cursor = cursor,
pageSize = limit,
)
},
)
return txHistoryItemsStore.getSyncOrNull(
@ -84,9 +100,14 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
return "tangem_pay_tx_history_${customerWalletAddress}_${cursor ?: INITIAL_CURSOR}"
}
private suspend fun fetch(customerWalletAddress: String, cursor: String?, pageSize: Int) {
private suspend fun fetch(
userWalletId: UserWalletId,
customerWalletAddress: String,
cursor: String?,
pageSize: Int,
) {
requestPerformer.runWithErrorLogs(TAG) {
val result = requestPerformer.request { authHeader ->
val result = requestPerformer.request(userWalletId) { authHeader ->
visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor)
}.result
val items = txHistoryItemConverter.convertList(result.transactions).filterNotNull()

View file

@ -1,19 +1,20 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.Either.Companion.catch
import arrow.core.left
import arrow.core.raise.catch
import arrow.core.right
import com.squareup.moshi.Moshi
import com.tangem.core.error.UniversalError
import com.squareup.wire.Instant
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.getAuthHeader
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
@ -25,20 +26,21 @@ import javax.inject.Inject
internal class TangemPayRequestPerformer @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val environmentConfigStorage: EnvironmentConfigStorage,
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
) {
private val customerWalletAddress = MutableStateFlow<String?>(null)
private val refreshTokensMutex = Mutex()
private var refreshTokensJob: Deferred<VisaAuthTokens>? = null
private var refreshTokensJob: Deferred<TangemPayAuthTokens>? = null
private val errorConverter = TangemPayErrorConverter(moshi)
suspend fun <T : Any> runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either<UniversalError, T> {
@Deprecated("Do not use this method")
suspend fun <T : Any> runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either<VisaApiError, T> {
return try {
val result = requestBlock()
Either.Right(result)
@ -56,54 +58,81 @@ internal class TangemPayRequestPerformer @Inject constructor(
}
suspend fun <T : Any> makeSafeRequest(
userWalletId: UserWalletId,
requestBlock: suspend (header: String) -> ApiResponse<T>,
): Either<VisaApiError, T> {
return catch { request(requestBlock) }
.mapLeft { exception ->
Timber.tag("TangemPayRequestPerformer").e(exception)
errorConverter.convert(exception)
}
return performRequest(userWalletId, requestBlock)
}
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> ApiResponse<T>): T =
withContext(dispatchers.io) {
performRequest(
requestBlock = requestBlock,
getTokens = ::getAccessTokens,
refreshTokens = ::refreshAuthTokens,
@Deprecated("Use perform request instead", replaceWith = ReplaceWith("performRequest"))
suspend fun <T : Any> request(
userWalletId: UserWalletId,
requestBlock: suspend (header: String) ->
ApiResponse<T>,
): T = withContext(dispatchers.io) {
performRequest(userWalletId, requestBlock = requestBlock)
// to keep behaviour as previous
.fold(
ifRight = { it },
ifLeft = { error -> error("Cannot perform request: $error") },
)
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> ApiResponse<T>,
getTokens: (suspend () -> VisaAuthTokens),
refreshTokens: (suspend () -> VisaAuthTokens)? = null,
): T = runCatching {
val tokens = getTokens()
val header = tokens.getAuthHeader()
requestBlock(header).getOrThrow()
}.getOrElse { error ->
val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED
if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) {
refreshOrJoin(refreshTokens)
performRequest(requestBlock, refreshTokens = null, getTokens = getTokens)
} else {
throw error
}
}
private suspend fun refreshOrJoin(refreshTokens: suspend () -> VisaAuthTokens): VisaAuthTokens {
val jobToAwait: Deferred<VisaAuthTokens> =
refreshTokensMutex.withLock {
val current = refreshTokensJob
if (current == null || current.isCompleted) {
coroutineScope {
async { refreshTokens() }.also { refreshTokensJob = it }
}
} else {
current
suspend fun <T : Any> performWithStaticToken(
requestBlock: suspend (header: String) -> ApiResponse<T>,
): Either<VisaApiError, T> = withContext(dispatchers.io) {
catch(
block = {
val staticToken =
environmentConfigStorage.getConfigSync().bffStaticToken ?: error("BFF static token is null")
when (val apiResponse = requestBlock(staticToken)) {
is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left()
is ApiResponse.Success<T> -> apiResponse.data.right()
}
},
catch = { errorConverter.convert(it).left() },
)
}
suspend fun <T : Any> performRequest(
userWalletId: UserWalletId,
requestBlock: suspend (header: String) -> ApiResponse<T>,
): Either<VisaApiError, T> = withContext(dispatchers.io) {
catch(
block = {
val tokens = getAccessTokens(userWalletId)
val now = Instant.now()
val accessExpiresAt = Instant.ofEpochSecond(tokens.expiresAt)
val refreshExpiresAt = Instant.ofEpochSecond(tokens.refreshExpiresAt)
val apiResponse: ApiResponse<T> = if (accessExpiresAt.isAfter(now)) {
requestBlock(tokens.getAuthHeader())
} else if (accessExpiresAt.isBefore(now) && refreshExpiresAt.isAfter(now)) {
val newTokens = refreshOrJoin(refreshTokens = { refreshAuthTokens(userWalletId) })
requestBlock(newTokens.getAuthHeader())
} else {
return@catch VisaApiError.RefreshTokenExpired.left()
}
when (apiResponse) {
is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left()
is ApiResponse.Success<T> -> apiResponse.data.right()
}
},
catch = { errorConverter.convert(it).left() },
).onLeft { visaApiError -> Timber.tag("TangemPayRequestPerformer").e(visaApiError.toString()) }
}
private suspend fun refreshOrJoin(refreshTokens: suspend () -> TangemPayAuthTokens): TangemPayAuthTokens {
val jobToAwait: Deferred<TangemPayAuthTokens> = refreshTokensMutex.withLock {
val current = refreshTokensJob
if (current == null || current.isCompleted) {
coroutineScope {
async { refreshTokens() }.also { refreshTokensJob = it }
}
} else {
current
}
}
val result = try {
jobToAwait.await()
} finally {
@ -116,28 +145,28 @@ internal class TangemPayRequestPerformer @Inject constructor(
return result
}
suspend fun getCustomerWalletAddress(): String {
suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String {
val existingAddress = customerWalletAddress.value
if (existingAddress != null) {
return existingAddress
}
val storedAddress = tangemPayStorage.getCustomerWalletAddress(
userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId,
userWalletId = userWalletId,
) ?: error("Can not find customer address")
customerWalletAddress.value = storedAddress
return storedAddress
}
private suspend fun getAccessTokens(): VisaAuthTokens {
val walletAddress = getCustomerWalletAddress()
private suspend fun getAccessTokens(userWalletId: UserWalletId): TangemPayAuthTokens {
val walletAddress = getCustomerWalletAddress(userWalletId)
val tokens = tangemPayStorage.getAuthTokens(walletAddress) ?: error("Auth tokens are not stored")
return tokens
}
private suspend fun refreshAuthTokens(): VisaAuthTokens {
val customerWalletAddress = getCustomerWalletAddress()
val refreshToken = getAccessTokens().refreshToken.value
private suspend fun refreshAuthTokens(userWalletId: UserWalletId): TangemPayAuthTokens {
val customerWalletAddress = getCustomerWalletAddress(userWalletId)
val refreshToken = getAccessTokens(userWalletId).refreshToken
val tokens = authDataSource.refreshAuthTokens(refreshToken)
.fold(
ifLeft = { error -> error("Cannot refresh tokens: ${error.message}") },

View file

@ -12,6 +12,8 @@ class TangemPayErrorConverter(moshi: Moshi) : Converter<Throwable, VisaApiError>
override fun convert(value: Throwable): VisaApiError {
return if (value is ApiResponseError.HttpException) {
if (value.code == ApiResponseError.HttpException.Code.NOT_FOUND) return VisaApiError.NotPaeraCustomer
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode

View file

@ -8,7 +8,8 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import javax.inject.Inject
internal class TangemPayWalletsManager @Inject constructor(
// TODO remove after implement wallet selector in pay
class TangemPayWalletsManager @Inject constructor(
private val manager: UserWalletsListManager,
private val repository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,

View file

@ -5,15 +5,13 @@ import com.squareup.moshi.Moshi
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.TangemPayAuthApi
import com.tangem.datasource.api.pay.models.request.*
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -21,6 +19,7 @@ import javax.inject.Inject
internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
private val visaAuthApi: TangemPayApi,
private val tangemPayAuthApi: TangemPayAuthApi,
private val dispatchers: CoroutineDispatcherProvider,
) : VisaAuthRemoteDataSource {
@ -66,15 +65,19 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
override suspend fun getCustomerWalletAuthChallenge(
customerWalletAddress: String,
customerWalletId: String,
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
request {
visaAuthApi.generateNonceByCustomerWallet(
GenerateNonceByCustomerWalletRequest(customerWalletAddress = customerWalletAddress),
tangemPayAuthApi.generateNonceByCustomerWallet(
request = GenerateNonceByCustomerWalletRequest(
customerWalletAddress = customerWalletAddress,
customerWalletId = customerWalletId,
),
).getOrThrow()
}.map { response ->
VisaAuthChallenge.Wallet(
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
challenge = response.nonce,
session = VisaAuthSession(response.sessionId),
)
}
}
@ -83,37 +86,42 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
sessionId: String,
signature: String,
nonce: String,
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
): Either<VisaApiError, TangemPayAuthTokens> = withContext(dispatchers.io) {
request {
visaAuthApi.getTokenByCustomerWallet(
GetTokenByCustomerWalletRequest(
tangemPayAuthApi.getTokenByCustomerWallet(
request = GetTokenByCustomerWalletRequest(
authType = "customer_wallet",
sessionId = sessionId,
signature = signature,
messageFormat = "Tangem Pay wants to sign in with your account. Nonce: $nonce",
),
).getOrThrow()
}.map { response ->
VisaAuthTokens(
response.result.accessToken,
VisaAuthTokens.RefreshToken(
response.result.refreshToken,
VisaAuthTokens.RefreshToken.Type.CardWallet,
),
TangemPayAuthTokens(
accessToken = response.accessToken,
expiresAt = response.expiresAt,
refreshToken = response.refreshToken,
refreshExpiresAt = response.refreshExpiresAt,
)
}
}
override suspend fun refreshCustomerWalletAuthTokens(
refreshToken: VisaAuthTokens.RefreshToken,
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
refreshToken: String,
): Either<VisaApiError, TangemPayAuthTokens> = withContext(dispatchers.io) {
request {
visaAuthApi.refreshCustomerWalletAccessToken(
RefreshCustomerWalletAccessTokenRequest(refreshToken = refreshToken.value),
tangemPayAuthApi.refreshCustomerWalletAccessToken(
request = RefreshCustomerWalletAccessTokenRequest(
authType = "customer_wallet",
refreshToken = refreshToken,
),
).getOrThrow()
}.map { response ->
VisaAuthTokens(
accessToken = response.result.accessToken,
refreshToken = refreshToken.copy(value = response.result.refreshToken),
TangemPayAuthTokens(
accessToken = response.accessToken,
expiresAt = response.expiresAt,
refreshToken = response.refreshToken,
refreshExpiresAt = response.refreshExpiresAt,
)
}
}