diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 830560dcc8..09b5ce82af 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -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, ): List + suspend fun getUnfulfilledReceiveRequirement(toSwapCurrencyStatus: SwapCurrencyStatus): AssetRequirementsCondition? + fun findProvidersForPair( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9464e5f5b1..554c14c48a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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, ): List { - 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, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt index 1f7e5c8d85..234f50b740 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt @@ -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() + } } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index c1b113f3c2..a2a1637ac1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 86595797c2..1d2ca68bb9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -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 { + 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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 0769f7b5b7..59ef6fac05 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index a7fe90f04a..dbb2a99da1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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, ): 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,