Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-04 18:46:38 +04:00
parent b210d3b7fd
commit c9804fa0ed
16 changed files with 166 additions and 51 deletions

View file

@ -1,9 +1,12 @@
package com.tangem.data.pay.datasource
import arrow.core.Either
import arrow.core.left
import arrow.core.raise.either
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.WithdrawalSignatureResult
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.TangemPayInitialCredentials
@ -28,12 +31,21 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
.bind()
}
override suspend fun getWithdrawalSignature(cardId: String, hash: String): Either<Throwable, String> {
override suspend fun getWithdrawalSignature(
cardId: String,
hash: String,
): Either<Throwable, WithdrawalSignatureResult> {
return when (val signResult = tangemSdkManager.getWithdrawalSignature(cardId, hash)) {
is CompletionResult.Failure<*> -> Either.Left(
IllegalStateException("TangemPay sign hash failed: ${signResult.error}"),
)
is CompletionResult.Success<String> -> Either.Right(signResult.data)
is CompletionResult.Failure<*> -> {
if (signResult.error is TangemSdkError.UserCancelled) {
Either.Right(WithdrawalSignatureResult.Cancelled)
} else {
signResult.error.left()
}
}
is CompletionResult.Success<String> -> {
Either.Right(WithdrawalSignatureResult.Success(signResult.data))
}
}
}
}

View file

@ -11,7 +11,9 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.WithdrawalSignatureResult
import com.tangem.domain.pay.repository.TangemPaySwapRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.wallets.legacy.UserWalletsListManager
@ -39,7 +41,7 @@ internal class DefaultTangemPaySwapRepository @Inject constructor(
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
): Either<UniversalError, Unit> {
): Either<UniversalError, WithdrawalResult> {
val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId)
if (amountInCents.isNullOrEmpty()) return Either.Left(VisaApiError.WithdrawalDataError)
return requestHelper.makeSafeRequest(userWalletId) { authHeader ->
@ -48,25 +50,35 @@ internal class DefaultTangemPaySwapRepository @Inject constructor(
}.map { data ->
val result = data.result
if (result == null) return Either.Left(VisaApiError.WithdrawalDataError)
val signature = authDataSource.getWithdrawalSignature(cardId = getCardId(userWalletId), hash = result.hash)
.getOrNull()
if (signature == null) return Either.Left(VisaApiError.SignWithdrawError)
val signatureResult = authDataSource.getWithdrawalSignature(
cardId = getCardId(userWalletId),
hash = result.hash,
).getOrNull()
requestHelper.makeSafeRequest(userWalletId) { authHeader ->
val request = WithdrawRequest(
amountInCents = amountInCents,
recipientAddress = receiverAddress,
adminSalt = result.salt,
senderAddress = result.senderAddress,
adminSignature = signature,
)
tangemPayApi.withdraw(authHeader = authHeader, body = request)
}
.mapLeft { return Either.Left(VisaApiError.WithdrawError) }
.map { response ->
val orderId = response.result?.orderId
if (orderId != null) tangemPayStorage.storeWithdrawOrder(userWalletId, orderId)
return when (signatureResult) {
is WithdrawalSignatureResult.Cancelled -> {
Either.Right(WithdrawalResult.Cancelled)
}
is WithdrawalSignatureResult.Success -> {
requestHelper.makeSafeRequest(userWalletId) { authHeader ->
val request = WithdrawRequest(
amountInCents = amountInCents,
recipientAddress = receiverAddress,
adminSalt = result.salt,
senderAddress = result.senderAddress,
adminSignature = signatureResult.signature,
)
tangemPayApi.withdraw(authHeader = authHeader, body = request)
}
.mapLeft { return Either.Left(VisaApiError.WithdrawError) }
.map { response ->
val orderId = response.result?.orderId
if (orderId != null) tangemPayStorage.storeWithdrawOrder(userWalletId, orderId)
WithdrawalResult.Success
}
}
null -> return Either.Left(VisaApiError.SignWithdrawError)
}
}
}

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.repository.TangemPaySwapRepository
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import java.math.BigDecimal
@ -18,7 +19,7 @@ internal class DefaultTangemPayWithdrawUseCase @Inject constructor(
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
): Either<UniversalError, Unit> {
): Either<UniversalError, WithdrawalResult> {
return repository.withdraw(
userWalletId = userWalletId,
cryptoAmount = cryptoAmount,

View file

@ -72,5 +72,11 @@ sealed interface FeedbackEmailType {
val item: TangemPayTxHistoryItem,
override val walletMetaInfo: WalletMetaInfo,
) : Visa()
data class Withdrawal(
override val walletMetaInfo: WalletMetaInfo,
val providerName: String,
val txId: String,
) : Visa()
}
}

View file

@ -74,6 +74,7 @@ class SendFeedbackEmailUseCase(
is FeedbackEmailType.Visa.Activation,
is FeedbackEmailType.Visa.DirectUserRequest,
is FeedbackEmailType.Visa.FailedIssueCard,
is FeedbackEmailType.Visa.Withdrawal,
-> {
append(resources.getStringSafe(R.string.feedback_data_collection_message))
skipLine()

View file

@ -37,6 +37,7 @@ internal class EmailMessageBodyResolver(
is FeedbackEmailType.Visa.FailedIssueCard -> addUserRequestBody(type.walletMetaInfo)
is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.walletMetaInfo, type.visaTxDetails)
is FeedbackEmailType.Visa.DisputeV2 -> addTangemPayRequestBody(type.walletMetaInfo, type.item)
is FeedbackEmailType.Visa.Withdrawal -> addTangemPayWithdrawalRequestBody(type)
}
return build()
@ -51,6 +52,33 @@ internal class EmailMessageBodyResolver(
addTangemPayTxInfo(item)
}
private suspend fun FeedbackDataBuilder.addTangemPayWithdrawalRequestBody(
type: FeedbackEmailType.Visa.Withdrawal,
) {
addUserWalletMetaInfo(type.walletMetaInfo)
addDelimiter()
val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
val blockchainInfo = blockchainError?.let {
feedbackRepository.getBlockchainInfo(
userWalletId = userWalletId,
blockchainId = blockchainError.blockchainId,
derivationPath = blockchainError.derivationPath,
)
}
if (blockchainInfo != null) {
addBlockchainError(blockchainInfo, blockchainError)
addDelimiter()
}
addSwapInfo(providerName = type.providerName, txId = type.txId)
addDelimiter()
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
}
private suspend fun FeedbackDataBuilder.addVisaRequestBody(
walletMetaInfo: WalletMetaInfo,
visaTxDetails: VisaTxDetails,

View file

@ -25,6 +25,7 @@ internal class EmailMessageTitleResolver(private val resources: Resources) {
is FeedbackEmailType.Visa.Dispute,
is FeedbackEmailType.Visa.DisputeV2,
is FeedbackEmailType.Visa.FailedIssueCard,
is FeedbackEmailType.Visa.Withdrawal,
-> R.string.feedback_preface_support
is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative
is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed

View file

@ -43,6 +43,8 @@ internal class EmailSubjectResolver(private val resources: Resources) {
is FeedbackEmailType.Visa.Dispute,
is FeedbackEmailType.Visa.DisputeV2,
-> "[Visa] [DISPUTE] {auto-filled subject}"
is FeedbackEmailType.Visa.Withdrawal,
-> "[Visa] [WITHDRAWAL] {auto-filled subject}"
}
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.pay
enum class WithdrawalResult {
Cancelled, Success
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.pay.datasource
import arrow.core.Either
import com.tangem.domain.pay.model.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.TangemPayInitialCredentials
@ -10,5 +11,5 @@ interface TangemPayAuthDataSource {
suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, TangemPayAuthTokens>
suspend fun getWithdrawalSignature(cardId: String, hash: String): Either<Throwable, String>
suspend fun getWithdrawalSignature(cardId: String, hash: String): Either<Throwable, WithdrawalSignatureResult>
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.pay.model
sealed class WithdrawalSignatureResult {
data class Success(val signature: String) : WithdrawalSignatureResult()
data object Cancelled : WithdrawalSignatureResult()
}

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import java.math.BigDecimal
interface TangemPaySwapRepository {
@ -13,5 +14,5 @@ interface TangemPaySwapRepository {
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
): Either<UniversalError, Unit>
): Either<UniversalError, WithdrawalResult>
}

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import java.math.BigDecimal
interface TangemPayWithdrawUseCase {
@ -13,5 +14,5 @@ interface TangemPayWithdrawUseCase {
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
): Either<UniversalError, Unit>
): Either<UniversalError, WithdrawalResult>
}

View file

@ -55,5 +55,7 @@ sealed class SwapTransactionState {
data class ExpressError(val error: ExpressDataError) : Error()
data object UnknownError : Error()
data class TangemPayWithdrawalError(val txId: String) : Error()
}
}

View file

@ -28,6 +28,9 @@ internal class SwapTransactionErrorStateConverter(
)
}
SwapTransactionState.Error.UnknownError -> SwapAlertUM.GenericError(onDismiss)
is SwapTransactionState.Error.TangemPayWithdrawalError -> SwapAlertUM.GenericError(
onConfirmClick = { onSupportClick(value.txId) },
)
}
}
}

View file

@ -38,6 +38,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.promo.models.StoryContentIds
@ -806,31 +807,38 @@ internal class SwapModel @Inject constructor(
)
.onLeft {
startLoadingQuotesFromLastState()
makeDefaultAlert()
onTangemPayWithdrawalError(swapTransactionState.storeData.txExternalId)
}
.onRight {
val txUrl = swapTransactionState.storeData.txExternalUrl
swapInteractor.storeSwapTransaction(
currencyToSend = swapTransactionState.storeData.currencyToSend,
currencyToGet = swapTransactionState.storeData.currencyToGet,
fromAccount = swapTransactionState.storeData.fromAccount,
toAccount = swapTransactionState.storeData.toAccount,
amount = swapTransactionState.storeData.amount,
swapProvider = swapTransactionState.storeData.swapProvider,
swapDataModel = swapTransactionState.storeData.swapDataModel,
txExternalUrl = txUrl,
timestamp = System.currentTimeMillis(),
txExternalId = swapTransactionState.storeData.txExternalId,
averageDuration = null,
)
uiState = stateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
swapTransactionState = swapTransactionState,
dataState = dataState,
txUrl = txUrl.orEmpty(),
onExploreClick = { if (txUrl != null) urlOpener.openUrl(txUrl) },
)
swapRouter.openScreen(SwapNavScreen.Success)
.onRight { result: WithdrawalResult ->
when (result) {
WithdrawalResult.Cancelled -> {
startLoadingQuotesFromLastState()
}
WithdrawalResult.Success -> {
val txUrl = swapTransactionState.storeData.txExternalUrl
swapInteractor.storeSwapTransaction(
currencyToSend = swapTransactionState.storeData.currencyToSend,
currencyToGet = swapTransactionState.storeData.currencyToGet,
fromAccount = swapTransactionState.storeData.fromAccount,
toAccount = swapTransactionState.storeData.toAccount,
amount = swapTransactionState.storeData.amount,
swapProvider = swapTransactionState.storeData.swapProvider,
swapDataModel = swapTransactionState.storeData.swapDataModel,
txExternalUrl = txUrl,
timestamp = System.currentTimeMillis(),
txExternalId = swapTransactionState.storeData.txExternalId,
averageDuration = null,
)
uiState = stateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
swapTransactionState = swapTransactionState,
dataState = dataState,
txUrl = txUrl.orEmpty(),
onExploreClick = { if (txUrl != null) urlOpener.openUrl(txUrl) },
)
swapRouter.openScreen(SwapNavScreen.Success)
}
}
}
}
@ -1627,6 +1635,29 @@ internal class SwapModel @Inject constructor(
analyticsEventHandler.send(event = event)
}
private fun onTangemPayWithdrawalError(txId: String?) {
uiState = stateBuilder.createErrorTransactionAlert(
uiState = uiState,
error = SwapTransactionState.Error.TangemPayWithdrawalError(txId.orEmpty()),
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onTangemPaySupportClick,
isReverseSwapPossible = isReverseSwapPossible(),
)
}
private fun onTangemPaySupportClick(txId: String?) {
modelScope.launch {
val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId)
.getOrElse { error("CardInfo must be not null") }
val email = FeedbackEmailType.Visa.Withdrawal(
walletMetaInfo = metaInfo,
providerName = dataState.selectedProvider?.name.orEmpty(),
txId = txId.orEmpty(),
)
sendFeedbackEmailUseCase(email)
}
}
private fun onFailedTxEmailClick(errorMessage: String) {
modelScope.launch {
val transaction = dataState.swapDataModel?.transaction