Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-25 14:47:02 +04:00
parent f229849259
commit dacc49b17e
7 changed files with 241 additions and 5 deletions

View file

@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
@ -29,6 +30,8 @@ interface SwapInteractor {
pairs: List<SwapPairLeast>,
): List<SwapProvider>
suspend fun getUnfulfilledReceiveRequirement(toSwapCurrencyStatus: SwapCurrencyStatus): AssetRequirementsCondition?
fun findProvidersForPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -46,6 +46,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AllowanceInfo
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
@ -207,12 +208,14 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
pairs: List<SwapPairLeast>,
): List<SwapProvider> {
val requirements = getAssetRequirementsUseCase.invoke(
val fromRequirements = getAssetRequirementsUseCase.invoke(
fromSwapCurrencyStatus.userWalletId,
fromSwapCurrencyStatus.currency,
).getOrNull()
if (!rampStateManager.checkAssetRequirements(requirements)) {
val isToFulfilled = getUnfulfilledReceiveRequirement(toSwapCurrencyStatus) == null
if (!rampStateManager.checkAssetRequirements(fromRequirements) || !isToFulfilled) {
return emptyList()
}
@ -223,6 +226,17 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
override suspend fun getUnfulfilledReceiveRequirement(
toSwapCurrencyStatus: SwapCurrencyStatus,
): AssetRequirementsCondition? {
val requirements = getAssetRequirementsUseCase.invoke(
toSwapCurrencyStatus.userWalletId,
toSwapCurrencyStatus.currency,
).getOrNull()
return requirements?.takeUnless { rampStateManager.checkAssetRequirements(it) }
}
override suspend fun findBestQuote(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -4,6 +4,7 @@ import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import io.mockk.coEvery
import io.mockk.every
@ -156,5 +157,94 @@ internal class SwapInteractorImplFindProvidersForPairTest : SwapInteractorImplTe
// Then
assertThat(result).containsExactly(providerA, providerB)
}
@Test
fun `should return empty list when destination asset requires association even if source is fulfilled`() =
runTest {
// Arrange — source has no requirements, but the destination (e.g. unassociated Hedera HTS token)
// requires an on-chain opt-in. Without this check the swap would proceed and the payout would
// get stuck (AND-Hedera ERC20/HTS association).
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(
networkRawId = btcNetwork,
contractAddress = "0xAbc",
isCoin = false,
)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0xAbc",
providers = listOf(buildSwapProvider(ExchangeProviderType.CEX, "A")),
)
val toRequirement = AssetRequirementsCondition.PaidTransaction
coEvery {
getAssetRequirementsUseCase.invoke(any(), fromStatus.currency)
} returns null.right()
coEvery {
getAssetRequirementsUseCase.invoke(any(), toStatus.currency)
} returns toRequirement.right()
every { rampStateManager.checkAssetRequirements(null) } returns true
every { rampStateManager.checkAssetRequirements(toRequirement) } returns false
// Act
val result = sut.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
pairs = listOf(pair),
)
// Assert
assertThat(result).isEmpty()
}
}
@Nested
inner class GetUnfulfilledReceiveRequirement {
@Test
fun `should return requirement when destination asset requirement is not fulfilled`() = runTest {
// Arrange
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false)
val requirement = AssetRequirementsCondition.PaidTransaction
coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns requirement.right()
every { rampStateManager.checkAssetRequirements(requirement) } returns false
// Act
val result = sut.getUnfulfilledReceiveRequirement(toStatus)
// Assert
assertThat(result).isEqualTo(requirement)
}
@Test
fun `should return null when destination asset requirement is fulfilled`() = runTest {
// Arrange
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false)
val requirement = AssetRequirementsCondition.PaidTransaction
coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns requirement.right()
every { rampStateManager.checkAssetRequirements(requirement) } returns true
// Act
val result = sut.getUnfulfilledReceiveRequirement(toStatus)
// Assert
assertThat(result).isNull()
}
@Test
fun `should return null when there is no destination asset requirement`() = runTest {
// Arrange
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false)
coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns null.right()
every { rampStateManager.checkAssetRequirements(null) } returns true
// Act
val result = sut.getUnfulfilledReceiveRequirement(toStatus)
// Assert
assertThat(result).isNull()
}
}
}

View file

@ -78,6 +78,7 @@ 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
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
@ -650,7 +651,7 @@ internal class SwapModel @Inject constructor(
pairs = dataState.pairs,
)
if (toProvidersList.isEmpty()) {
handleSwapNotSupported(
handlePairUnavailable(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
toSwapCurrencyStatus = newToSwapCurrencyStatus,
)
@ -714,7 +715,7 @@ internal class SwapModel @Inject constructor(
pairs = pairs,
)
if (providerList.isEmpty()) {
handleSwapNotSupported(
handlePairUnavailable(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
@ -927,6 +928,16 @@ internal class SwapModel @Inject constructor(
)
return
}
if (toProvidersList.isEmpty()) {
modelScope.launch {
handlePairUnavailable(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
}
return
}
if (!isSilent) {
uiState = stateBuilder.createQuotesLoadingState(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -1318,6 +1329,15 @@ internal class SwapModel @Inject constructor(
return
}
modelScope.launch(dispatchers.main) {
val toRequirement = swapInteractor.getUnfulfilledReceiveRequirement(toSwapCurrencyStatus)
if (toRequirement != null) {
handleDestinationRequirementBlocked(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
requirement = toRequirement,
)
return@launch
}
runCatching(dispatchers.io) {
swapInteractor.onSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -2238,6 +2258,50 @@ internal class SwapModel @Inject constructor(
)
}
private suspend fun handlePairUnavailable(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
) {
val toRequirement = swapInteractor.getUnfulfilledReceiveRequirement(toSwapCurrencyStatus)
if (toRequirement != null) {
handleDestinationRequirementBlocked(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
requirement = toRequirement,
)
} else {
handleSwapNotSupported(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
}
}
private fun handleDestinationRequirementBlocked(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
requirement: AssetRequirementsCondition,
) {
singleTaskScheduler.cancelTask()
lastReducedBalanceBy.value = BigDecimal.ZERO
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
uiState = stateBuilder.createDestinationRequirementBlockedState(
uiStateHolder = uiState,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
requirement = requirement,
onAssociateClick = {
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = toSwapCurrencyStatus.userWalletId,
currency = toSwapCurrencyStatus.currency,
),
)
},
)
}
private fun handleSwapNotSupported(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -20,6 +20,7 @@ import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -78,6 +79,19 @@ internal class SwapNotificationsFactory(
)
}
fun getDestinationRequirementNotifications(
requirement: AssetRequirementsCondition,
onAssociateClick: () -> Unit,
): ImmutableList<NotificationUM> {
val notification = when (requirement) {
is AssetRequirementsCondition.RequiredTrustline ->
SwapNotificationUM.Warning.TokenTrustlineRequired(onAssociateClick)
else ->
SwapNotificationUM.Warning.TokenAssociationRequired(onAssociateClick)
}
return persistentListOf(notification)
}
fun getQuotesErrorStateNotifications(
expressDataError: ExpressDataError,
fromToken: CryptoCurrency,

View file

@ -149,6 +149,28 @@ internal object SwapNotificationUM {
),
)
data class TokenAssociationRequired(
val onAssociateClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.warning_hedera_missing_token_association_title),
subtitle = resourceReference(R.string.warning_receive_blocked_hedera_token_association_required_message),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_hedera_missing_token_association_button_title),
onClick = onAssociateClick,
),
)
data class TokenTrustlineRequired(
val onAssociateClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.warning_token_trustline_title),
subtitle = resourceReference(R.string.warning_receive_blocked_token_trustline_required_message),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_token_trustline_button_title),
onClick = onAssociateClick,
),
)
data class NeedReserveToCreateAccount(
val receiveAmount: String,
val receiveToken: String,

View file

@ -35,6 +35,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapProviderResolver
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
@ -455,6 +456,34 @@ internal class StateBuilder(
uiStateHolder: SwapStateHolder,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
): SwapStateHolder = createBlockedSwapState(
uiStateHolder = uiStateHolder,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
)
fun createDestinationRequirementBlockedState(
uiStateHolder: SwapStateHolder,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
requirement: AssetRequirementsCondition,
onAssociateClick: () -> Unit,
): SwapStateHolder = createBlockedSwapState(
uiStateHolder = uiStateHolder,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
notifications = notificationsFactory.getDestinationRequirementNotifications(
requirement = requirement,
onAssociateClick = onAssociateClick,
),
)
private fun createBlockedSwapState(
uiStateHolder: SwapStateHolder,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
notifications: ImmutableList<NotificationUM>,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
@ -482,7 +511,7 @@ internal class StateBuilder(
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
notifications = notifications,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = false,