Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-28 11:36:41 +01:00
parent 40f7df2c5d
commit 1ce3399a02
15 changed files with 890 additions and 65 deletions

View file

@ -68,7 +68,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.usecase.CalculateAmountUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
@ -155,7 +155,7 @@ internal class SwapModel @Inject constructor(
private val urlOpener: UrlOpener,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val tangemPayWithdrawWithSwapUseCase: TangemPayWithdrawWithSwapUseCase,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
@ -738,14 +738,18 @@ internal class SwapModel @Inject constructor(
feePaidCryptoCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
)
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
if (isTangemPayWithdrawal()) {
refreshTransferUIStateIfNeeded()
} else {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
}
}
is SwapState.QuotesLoadedState, is SwapState.SwapError -> Unit
}
}
private fun refreshTransferUIStateAfterFeeUpdateIfNeeded(
private fun refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
fee: Fee? = null,
) {
@ -774,6 +778,7 @@ internal class SwapModel @Inject constructor(
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
isTangemPayWithdrawal = isTangemPayWithdrawal(),
)
}
}
@ -1287,61 +1292,122 @@ internal class SwapModel @Inject constructor(
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null || fee == null) {
TangemLogger.e("onTransferClick: missing currency status or fee, aborting")
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) {
TangemLogger.e("onTransferClick: missing currency status, aborting")
showAlert()
return
}
val transferState = dataState.currentTransferState ?: return
uiState = swapTransferStateBuilder.createTransferInProgressState(uiState)
modelScope.launch(dispatchers.main) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
},
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
when {
isTangemPayWithdrawal() -> withdrawTangemPay(
transferState = transferState,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
fee != null -> sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
transferState = transferState,
fee = fee,
)
else -> {
TangemLogger.e("onTransferClick: Illegal state, aborting")
showAlert()
},
ifRight = { txHash ->
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txHash,
currency = fromSwapCurrencyStatus.currency,
).getOrElse {
TangemLogger.i("onTransferClick: tx hash explore not supported")
""
}
updateWalletBalance()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
appCurrency = selectedAppCurrencyFlow.value,
isAccountsMode = isAccountsMode,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = null,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
},
)
}
}
}
}
private suspend fun withdrawTangemPay(
transferState: SwapState.Transfer,
toSwapCurrencyStatus: SwapCurrencyStatus,
) {
swapTransferInteractor.withdrawTangemPay(
userWallet = transferState.userWallet,
cryptoAmount = transferState.sendingAmount,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
.onLeft { error ->
TangemLogger.e(
messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}",
)
showAlert()
}
.onRight { result ->
when (result) {
WithdrawalResult.Cancelled -> startLoadingQuotesFromLastState()
WithdrawalResult.Success -> updateTransferModeTangemPayState()
}
}
}
private fun updateTransferModeTangemPayState() {
uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
dataState = dataState,
onExploreClick = {
val txUrl = uiState.successState?.txUrl.orEmpty()
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
}
private suspend fun sendTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
transferState: SwapState.Transfer,
fee: Fee,
) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
},
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
showAlert()
},
ifRight = { txHash ->
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txHash,
currency = fromSwapCurrencyStatus.currency,
).getOrElse {
TangemLogger.i("onTransferClick: tx hash explore not supported")
""
}
updateWalletBalance()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
appCurrency = selectedAppCurrencyFlow.value,
isAccountsMode = isAccountsMode,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = null,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
},
)
}
private suspend fun processTangemPayWithdrawal(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapTransactionState: SwapTransactionState.TangemPayWithdrawalData,
) {
tangemPayWithdrawUseCase(
tangemPayWithdrawWithSwapUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoAmount = swapTransactionState.cryptoAmount,
cryptoCurrencyId = swapTransactionState.cryptoCurrencyId,
@ -2224,7 +2290,7 @@ internal class SwapModel @Inject constructor(
if (newState is FeeSelectorUM.Error) {
TangemLogger.e("loadFee: ${newState.error}, isHidden = true")
refreshTransferUIStateAfterFeeUpdateIfNeeded()
refreshTransferUIStateIfNeeded()
uiState = stateBuilder.createFeeErrorState(
uiStateHolder = uiState,
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
@ -2234,7 +2300,7 @@ internal class SwapModel @Inject constructor(
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
return
}
refreshTransferUIStateAfterFeeUpdateIfNeeded(
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)

View file

@ -210,6 +210,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
}
}
/**
* [isTangemPayWithdrawal] - if true - Tangem pay withdrawal done with no fee, skip fee nullability check
*/
@Suppress("LongParameterList")
fun updateTransferButtonEnableState(
dataState: SwapProcessDataState,
@ -218,6 +221,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
isTangemPayWithdrawal: Boolean,
): SwapStateHolder {
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
@ -229,7 +233,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
return uiStateHolder.copy(
notifications = notifications,
swapButton = uiStateHolder.swapButton.copy(
isEnabled = getTransferButtonEnabled(notifications, fee),
isEnabled = getTransferButtonEnabled(notifications, fee, isTangemPayWithdrawal),
),
transferFooter = getSendingFooterText(
dataState = dataState,
@ -240,8 +244,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
)
}
private fun getTransferButtonEnabled(notifications: ImmutableList<NotificationUM>, fee: Fee?): Boolean {
return fee != null && notifications.none { notification ->
private fun getTransferButtonEnabled(
notifications: ImmutableList<NotificationUM>,
fee: Fee?,
isTangemPayWithdrawal: Boolean,
): Boolean {
return (fee != null || isTangemPayWithdrawal) && notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
@ -373,4 +381,52 @@ internal class SwapTransferStateBuilder @Inject constructor(
),
)
}
fun createTangemPayWithdrawalSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
onExploreClick: () -> Unit,
): SwapStateHolder {
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
val transferState = requireNotNull(dataState.currentTransferState)
val amountValue = transferState.sendingAmount
val fiatAmount = getFormattedFiatAmount(
appCurrency = transferState.appCurrency,
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amountValue),
)
return uiState.copy(
successState = SwapSuccessStateHolder(
timestamp = System.currentTimeMillis(),
txUrl = "",
providerName = stringReference(""),
providerType = stringReference(""),
shouldShowStatusButton = false,
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = null,
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
),
toTitle = getCardAccountTitle(
account = toSwapCurrencyStatus.account,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
),
fromTokenAmount = stringReference(amountValue.toString()),
toTokenAmount = stringReference(amountValue.toString()),
fromTokenFiatAmount = fiatAmount,
toTokenFiatAmount = fiatAmount,
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
onExploreButtonClick = onExploreClick,
onStatusButtonClick = {},
),
)
}
}

View file

@ -317,6 +317,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.swapButton.isEnabled).isTrue()
@ -358,6 +359,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java)
@ -408,6 +410,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.transferFooter).isEqualTo(
@ -452,6 +455,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.transferFooter).isEqualTo(
@ -518,6 +522,139 @@ internal class SwapTransferStateBuilderTest {
)
}
@Test
fun `GIVEN null fee but tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button is enabled with no footer`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("1"),
toAmount = BigDecimal("1"),
isAccountsMode = false,
)
val dataState = SwapProcessDataState()
val uiState = baseStateHolder().copy(
swapButton = SwapButton(
walletInteractionIcon = null,
isEnabled = false,
mode = SwapButton.Mode.TRANSFER,
onClick = {},
),
)
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = true,
)
assertThat(result.swapButton.isEnabled).isTrue()
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER)
// fee is null → footer is omitted, but the button stays enabled because it is a Tangem Pay withdrawal
assertThat(result.transferFooter).isNull()
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN null fee and not tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button stays disabled`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("1"),
toAmount = BigDecimal("1"),
isAccountsMode = false,
)
val dataState = SwapProcessDataState()
val uiState = baseStateHolder().copy(
swapButton = SwapButton(
walletInteractionIcon = null,
isEnabled = false,
mode = SwapButton.Mode.TRANSFER,
onClick = {},
),
)
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = false,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `GIVEN transfer dataState WHEN createTangemPayWithdrawalSuccessState THEN feeless transfer success holder is built`() {
val sendingAmount = BigDecimal("1.5")
val transferState = buildTransferState(
fromAmount = sendingAmount,
toAmount = sendingAmount,
isAccountsMode = true,
)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
currentTransferState = transferState,
)
val onExploreClick = {}
val appCurrency = transferState.appCurrency
val expectedFiat = stringReference(
fromCurrencyStatus.status.value.fiatRate!!.multiply(sendingAmount).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
},
)
val before = System.currentTimeMillis()
val result = sut.createTangemPayWithdrawalSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
onExploreClick = onExploreClick,
)
val after = System.currentTimeMillis()
val success = requireNotNull(result.successState)
assertThat(success.isTransferMode).isTrue()
assertThat(success.shouldShowStatusButton).isFalse()
assertThat(success.fee).isNull()
assertThat(success.txUrl).isEmpty()
assertThat(success.providerName).isEqualTo(stringReference(""))
assertThat(success.providerType).isEqualTo(stringReference(""))
assertThat(success.providerIcon).isEmpty()
assertThat(success.rate).isEqualTo(TextReference.EMPTY)
assertThat(success.timestamp).isAtLeast(before)
assertThat(success.timestamp).isAtMost(after)
assertThat(success.fromTokenAmount).isEqualTo(stringReference(sendingAmount.toString()))
assertThat(success.toTokenAmount).isEqualTo(stringReference(sendingAmount.toString()))
assertThat(success.fromTokenFiatAmount).isEqualTo(expectedFiat)
assertThat(success.toTokenFiatAmount).isEqualTo(expectedFiat)
assertThat(success.fromTokenIconState).isEqualTo(fromIcon)
assertThat(success.toTokenIconState).isEqualTo(toIcon)
assertThat(success.onExploreButtonClick).isEqualTo(onExploreClick)
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
val expectedIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedName = portfolioAccount.accountName.toUM().value
assertThat(success.fromTitle).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
name = expectedName,
icon = expectedIcon,
),
)
assertThat(success.toTitle).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedName,
icon = expectedIcon,
),
)
}
private fun assertSharedCardShape(
result: SwapStateHolder,
transferState: SwapState.Transfer,