Updated on 2026-08-14
This commit is contained in:
parent
f229849259
commit
dacc49b17e
7 changed files with 241 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue