From defaa25c3ded258fc5e9c996426d1c76a77ea683 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 15:18:02 +0400 Subject: [PATCH 01/23] Updated on 2026-08-14 --- .../child/wallet/model/intents/WalletWarningsClickIntents.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 9d839be728..38ab1f0619 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -7,6 +7,7 @@ import com.tangem.common.ui.notifications.NotificationId import com.tangem.common.ui.userwallet.handle import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic.ButtonSupport import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped From 4a8e68706d321030411e8b8e82d58f7420408f03 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 13:37:43 +0100 Subject: [PATCH 02/23] Updated on 2026-08-14 --- .../component/DefaultWalletBackupComponent.kt | 16 ++++++++++++++++ .../ui/component/GoogleDriveFakeDoorDialog.kt | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt index 5c9921bdf6..c86ca0daee 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.hotwallet.WalletBackupComponent +import com.tangem.features.hotwallet.walletbackup.entity.Action import com.tangem.features.hotwallet.walletbackup.model.WalletBackupModel import com.tangem.features.hotwallet.walletbackup.ui.WalletBackupContent import dagger.assisted.Assisted @@ -26,6 +27,21 @@ internal class DefaultWalletBackupComponent @AssistedInject constructor( WalletBackupContent( state = state, modifier = modifier, + onBackClick = { + model.onAction(Action.OnBack) + }, + onHardwareWalletClick = { + model.onAction(Action.HardwareWallet) + }, + onRecoveryPhraseClick = { + model.onAction(Action.RecoveryPhrase) + }, + onGoogleDriveClick = { + model.onAction(Action.GoogleDriveBackup(isDialogShown = true)) + }, + onGoogleDriveFakeDoorDialogDismiss = { + model.onAction(Action.GoogleDriveBackup(isDialogShown = false)) + }, ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt new file mode 100644 index 0000000000..a986c53471 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt @@ -0,0 +1,17 @@ +package com.tangem.features.hotwallet.walletbackup.ui.component + +import androidx.compose.runtime.Composable +import com.tangem.common.R +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.extensions.stringResourceSafe + +@Composable +fun GoogleDriveFakeDoorDialog(onDismiss: () -> Unit) { + BasicDialog( + title = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_title), + message = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_message), + confirmButton = DialogButtonUM(onClick = onDismiss), + onDismissDialog = onDismiss, + ) +} \ No newline at end of file From f6067b82aba855fee769a623e0509995dff68e37 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 17:08:11 +0100 Subject: [PATCH 03/23] Updated on 2026-08-14 --- .../component/DefaultWalletBackupComponent.kt | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt index c86ca0daee..5c9921bdf6 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.hotwallet.WalletBackupComponent -import com.tangem.features.hotwallet.walletbackup.entity.Action import com.tangem.features.hotwallet.walletbackup.model.WalletBackupModel import com.tangem.features.hotwallet.walletbackup.ui.WalletBackupContent import dagger.assisted.Assisted @@ -27,21 +26,6 @@ internal class DefaultWalletBackupComponent @AssistedInject constructor( WalletBackupContent( state = state, modifier = modifier, - onBackClick = { - model.onAction(Action.OnBack) - }, - onHardwareWalletClick = { - model.onAction(Action.HardwareWallet) - }, - onRecoveryPhraseClick = { - model.onAction(Action.RecoveryPhrase) - }, - onGoogleDriveClick = { - model.onAction(Action.GoogleDriveBackup(isDialogShown = true)) - }, - onGoogleDriveFakeDoorDialogDismiss = { - model.onAction(Action.GoogleDriveBackup(isDialogShown = false)) - }, ) } From 3769c9791803f42286d306c6a982707f11828b41 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 17:48:20 +0100 Subject: [PATCH 04/23] Updated on 2026-08-14 --- .../ui/component/GoogleDriveFakeDoorDialog.kt | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt deleted file mode 100644 index a986c53471..0000000000 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.features.hotwallet.walletbackup.ui.component - -import androidx.compose.runtime.Composable -import com.tangem.common.R -import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButtonUM -import com.tangem.core.ui.extensions.stringResourceSafe - -@Composable -fun GoogleDriveFakeDoorDialog(onDismiss: () -> Unit) { - BasicDialog( - title = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_title), - message = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_message), - confirmButton = DialogButtonUM(onClick = onDismiss), - onDismissDialog = onDismiss, - ) -} \ No newline at end of file From 8a8f657d0385cc01bc437bb7aff57cb266c3aa5f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 18:40:51 +0500 Subject: [PATCH 05/23] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index a6a6b012a1..312a920261 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1382,6 +1382,8 @@ Target account is not created. Please change the amount to send. The amount to send must be at least %s Leave %s + A trustline for %s is required first. + Can\'t receive token Reduce by %s Reduce to %s Kindly be aware that your transaction may experience delays under specific fee settings From fccbac44c27c0e725c845f2a265704dbdc8cc4ac Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 00:44:19 +0500 Subject: [PATCH 06/23] Updated on 2026-08-14 --- .../SwapInteractorImplLoadFeeForDexTest.kt | 736 ++++++++++++++++++ .../SwapInteractorImplOtherNativeFeeTest.kt | 345 ++++++++ .../feature/swap/StateBuilderFeeStateTest.kt | 385 +++++++++ ...SwapNotificationsFactoryFeeWarningsTest.kt | 486 ++++++++++++ 4 files changed, 1952 insertions(+) create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt new file mode 100644 index 0000000000..b981644028 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt @@ -0,0 +1,736 @@ +package com.tangem.feature.swap.domain + +import android.util.Base64 +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionExtras +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.FeePaidCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.ui.SwapState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Characterization tests for the private fee-loading paths of [SwapInteractorImpl] reached by + * the DEX provider branch: + * + * - `loadFeeForDex` + * - `getFeeDataForDexSwap` (EVM) + * - `getFeeDataForSolanaDexSwap` (Solana) + * - the `patchTransactionFeeForSwap` 12% gas-limit bump applied on EVM DEX + * + * Driven through the public [SwapInteractorImpl.findBestQuote] entry point with carefully + * stubbed dependencies so the DEX-fee branch executes deterministically. + * + * [REDACTED_TASK_KEY] — these tests are intentionally pinned to the **current** behavior so that the + * upcoming refactor (extraction into `DexSwapFeeCalculator`) is provably equivalent. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val solanaNetwork = Blockchain.Solana.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @BeforeEach + fun setup() { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") + coEvery { + getCurrencyCheckUseCase.invoke( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns CryptoCurrencyCheck( + dustValue = null, + reserveAmount = null, + minimumSendAmount = null, + existentialDeposit = null, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = null, + isMemoRequired = false, + ) + coEvery { + validateTransactionUseCase.invoke( + amount = any(), + fee = any(), + memo = any(), + destination = any(), + userWalletId = any(), + network = any(), + ) + } returns Unit.right() + coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { + firstArg>().map { rawId -> + QuoteStatus( + rawCurrencyId = rawId, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + fiatRateUSD = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + ), + ) + }.toSet() + } + coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() + every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false + coEvery { + getAllowanceInfoUseCase.invoke( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() + every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns + mockk(relaxed = true).right() + } + + @Test + fun `EVM DEX swap propagates extras destinationAddress sourceAddress and amount to getFeeUseCase`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", // 0.001 ETH + txTo = "0xRecipient", + txFrom = "0xSender", + txData = "0xPayload", + ) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + val capturedTxData = slot() + coEvery { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = capture(capturedTxData), + ) + } returns mockk(relaxed = true).right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — captured TransactionData carries the values from ExpressTransactionModel.DEX + assertThat(capturedTxData.isCaptured).isTrue() + val uncompiled = capturedTxData.captured as TransactionData.Uncompiled + assertThat(uncompiled.destinationAddress).isEqualTo("0xRecipient") + assertThat(uncompiled.sourceAddress).isEqualTo("0xSender") + // amount.value is the txValue moved-point-left by native decimals (18 for ETH) → 0.001 + // Use compareTo-equivalence to ignore the BigDecimal scale (0.001 vs 0.001000000000000000). + assertThat(uncompiled.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.001")) + // extras came from createTransactionExtrasUseCase + assertThat(uncompiled.extras).isNotNull() + } + + @Test + fun `EVM DEX swap with native balance ZERO surfaces SwapError UnknownError`() = runTest { + // Given — zero native balance triggers the early-raise in getFeeDataForDexSwap + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex(txValue = "0") + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — native-balance == 0 raises ExpressDataError.UnknownError up to SwapError + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = state as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.UnknownError) + // getFeeUseCase should NOT have been invoked because the balance check short-circuits first + coVerify(exactly = 0) { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } + } + + @Test + fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when txValue is null`() = runTest { + // Given — null txValue forces error("unable to get txValue") → IllegalStateException → fallback + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val gas = BigInteger.valueOf(150_000L) + val swapData = buildSwapDataModelDex(txValue = null, gas = gas) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + coEvery { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } returns mockk(relaxed = true).right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — fallback path is invoked with the gas from the express transaction model + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + } + + @Test + fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when createTransactionExtrasUseCase returns null`() = + runTest { + // Given — null extras → error("unable to create extras") → IllegalStateException → fallback + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val gas = BigInteger.valueOf(75_000L) + val swapData = buildSwapDataModelDex(txValue = "1000000000000000", gas = gas) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // Force createTransactionExtrasUseCase to return null → triggers the fallback path. + // The use case signature is Either; pass a Throwable Left. + every { + createTransactionExtrasUseCase.invoke(data = any(), network = any()) + } returns IllegalStateException("forced fail").left() + + coEvery { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } returns mockk(relaxed = true).right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + } + + @Test + fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when getFeeUseCase returns null`() = runTest { + // Given — getFeeUseCase Left → getOrNull() == null → error("unable to calculate fee") → fallback + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val gas = BigInteger.valueOf(50_000L) + val swapData = buildSwapDataModelDex(txValue = "1000000000000000", gas = gas) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns GetFeeError.UnknownError.left() + + coEvery { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = any(), + gasPrice = any(), + ) + } returns mockk(relaxed = true).right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + coVerify(exactly = 1) { + getEthSpecificFeeUseCase.invoke( + userWallet = any(), + cryptoCurrency = any(), + gasLimit = gas, + gasPrice = any(), + ) + } + } + + @Test + fun `Solana DEX uses TransactionData Compiled and skips the 12 percent gas patch`() = runTest { + // Given — Solana network forces the Compiled path. We capture the TransactionData and + // assert that the resulting fee value is the raw return of getFeeUseCase (no 1.12x scaling). + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(64) + mockkObject(SolanaTransactionHelper) + every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns ByteArray(64) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex(txData = "U29sYW5h") + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // Construct a deterministic Solana fee — Fee.Common with a known amount value. + val rawFeeAmount = BigDecimal("0.005000") + val rawFee: Fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = rawFeeAmount, + decimals = 9, + ), + ) + val txFee = TransactionFee.Single(normal = rawFee) + val capturedTxData = slot() + coEvery { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = capture(capturedTxData), + ) + } returns txFee.right() + + // When + sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — TransactionData passed to getFeeUseCase is Compiled (not Uncompiled) + assertThat(capturedTxData.isCaptured).isTrue() + assertThat(capturedTxData.captured).isInstanceOf(TransactionData.Compiled::class.java) + // No gas-patch is applied on the Solana path; the raw amount is preserved. + // Pinning behavior: Fee.Common is not a Fee.Ethereum, so increaseEthGasLimitInNeeded + // returns it unchanged → no 1.12x scaling. + assertThat(rawFee.amount.value).isEqualTo(rawFeeAmount) + } + + @Test + fun `Solana DEX size guard raises TooLargeSolanaTransactionError when formatted hash exceeds 1232 bytes on Cold wallet`() = + runTest { + // Given — formatted hash > 1232 bytes on a Cold wallet → SwapError(TooLargeSolanaTransactionError) + mockkStatic(Base64::class) + val oversizedBytes = ByteArray(1300) + every { Base64.decode(any(), any()) } returns oversizedBytes + mockkObject(SolanaTransactionHelper) + every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns oversizedBytes + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val coldWallet = mockk(relaxed = true) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ).let { status -> + SwapCurrencyStatus( + userWallet = coldWallet, + status = status.status, + account = status.account, + ) + } + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex(txData = "very-long-base64-content==") + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = state as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError) + } + + /** + * Surprising current behavior pinned here for the redesign: + * + * In `loadDexSwapData`, the local `txFeeState` produced by `loadFeeForDex(...).toTxFeeState(...)` + * is computed but NEVER PROPAGATED to `QuotesLoadedState.txFee`. The latter is sourced from + * the input `txFeeSealedState` parameter via `updateBalances`. This means the 12% gas patch + * is applied (the side-effect runs) but the patched value is then discarded for state + * purposes; only the side effects of `loadFeeForDex` (raising on Solana size limit, balance=0, + * etc.) survive. + * + * The 12% gas-patch math itself is fully covered by the planned Phase-2 PatchEthGasLimitForSwapTest; + * pinning it through the public API here would only assert the discarded result. + * + * [REDACTED_TASK_KEY] — flagged for Phase-2 author awareness; the refactor MUST decide whether to: + * (a) preserve the dead-store (unlikely), or + * (b) actually wire the loaded fee into the resulting state (the intended fix). + */ + + // region — local builders + + private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), + otherNativeFeeWei: BigDecimal? = null, + gas: BigInteger = BigInteger.valueOf(21_000L), + txTo: String = "0xRecipient", + txFrom: String = "0xSender", + ): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-123", + txTo = txTo, + txExtraId = null, + txFrom = txFrom, + txData = txData, + otherNativeFeeWei = otherNativeFeeWei, + gas = gas, + ), + ) + + // endregion +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt new file mode 100644 index 0000000000..fd66c18baf --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt @@ -0,0 +1,345 @@ +package com.tangem.feature.swap.domain + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionExtras +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.tokens.model.FeePaidCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapFeeState +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Characterization tests for the bridge-fee field `otherNativeFeeWei` flowing into the + * resulting [TxFeeState]. + * + * Pinned behavior: + * - `otherNativeFee` (BigDecimal) = `transaction.otherNativeFeeWei` shifted left by native + * decimals (18 for ETH). + * - `feeIncludeOtherNativeFee` of the resulting `TxFee.Legacy` equals `feeValue + otherNativeFee`. + * - When `otherNativeFeeWei == null`, `feeIncludeOtherNativeFee == feeValue`. + * - The `feeToCheckFunds` (the value used by `getFeeState`) equals + * `feeByPriority + otherNativeFee`. We assert this indirectly: when the native balance is + * BETWEEN `feeByPriority` and `feeByPriority + otherNativeFee`, the resulting + * `SwapFeeState` is `NotEnough` (not `Enough`). + * + * [REDACTED_TASK_KEY] — these tests exist to guarantee that the upcoming refactor does not silently + * drop the bridge protocol fee for DEX_BRIDGE providers. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @BeforeEach + fun setup() { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + // Default native balance is large; specific tests override. + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") + coEvery { + getCurrencyCheckUseCase.invoke( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns CryptoCurrencyCheck( + dustValue = null, + reserveAmount = null, + minimumSendAmount = null, + existentialDeposit = null, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = null, + isMemoRequired = false, + ) + coEvery { + validateTransactionUseCase.invoke( + amount = any(), + fee = any(), + memo = any(), + destination = any(), + userWalletId = any(), + network = any(), + ) + } returns Unit.right() + coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { + firstArg>().map { rawId -> + QuoteStatus( + rawCurrencyId = rawId, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + fiatRateUSD = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + ), + ) + }.toSet() + } + coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() + every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false + coEvery { + getAllowanceInfoUseCase.invoke( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() + every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns + mockk(relaxed = true).right() + } + + /** + * Surprising current behavior (caught while writing this test): + * + * In `loadDexSwapData`, the local `txFeeState` produced by `loadFeeForDex(...).toTxFeeState(...)` + * is computed but **never used** for the resulting `QuotesLoadedState.txFee`. The actual + * `txFee` field of the resulting state is populated from the input `txFeeSealedState` + * parameter via `updateBalances` → which means the `feeIncludeOtherNativeFee` etc. on + * the returned state come from whatever the caller passes in, NOT from the loaded fee. + * + * What IS observable through the public API: + * - `feeByPriority + otherNativeFee` enters `feeToCheckFunds` and drives `feeState` + * (Enough vs NotEnough). This is verified in the two tests below. + * + * The "feeIncludeOtherNativeFee on the result.txFee" assertion is intentionally NOT + * pinned here — that field is sourced from the caller's `txFeeSealedState` and a refactor + * that fixes this dead-store will not break this test class. + * + * [REDACTED_TASK_KEY] — flagged for discussion before Phase 2. + */ + + @Test + fun `bridge provider with non-zero otherNativeFeeWei loads exchange data and reaches getFeeUseCase`() = runTest { + // Given — DEX_BRIDGE with otherNativeFeeWei = 5e15 wei = 0.005 ETH + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", + otherNativeFeeWei = BigDecimal("5000000000000000"), + ) + stubExchangeData(dexBridgeProvider, quoteModel, swapData) + + val rawFee: Fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18), + ) + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = rawFee).right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — the bridge provider produces a QuotesLoadedState (no SwapError) + // and the swap data carries the otherNativeFeeWei. + val state = result[dexBridgeProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + val transaction = loaded.swapDataModel?.transaction as? ExpressTransactionModel.DEX + assertThat(transaction?.otherNativeFeeWei).isEqualTo(BigDecimal("5000000000000000")) + } + + @Test + fun `feeToCheckFunds includes otherNativeFee — NotEnough fires when balance covers fee but not fee plus otherNativeFee`() = + runTest { + // Given — DEX_BRIDGE swap, native balance = 0.002 ETH + // base fee = 0.001 ETH, otherNativeFee = 0.005 ETH → feeToCheck = 0.006 ETH > balance + // For a Coin swap the feeState branch checks: nativeBalance - spendAmount > fee + // We swap a Token (so fromToken != Coin) → branch becomes: nativeBalance > fee + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromTokenStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", + otherNativeFeeWei = BigDecimal("5000000000000000"), // 0.005 ETH + ) + stubExchangeData(dexBridgeProvider, quoteModel, swapData) + + // Native balance: 0.002 ETH — enough for base fee (0.001) but NOT for combined (0.006). + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.002") + + val baseFeeValue = BigDecimal("0.001") + val rawFee: Fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = baseFeeValue, decimals = 18), + ) + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = rawFee).right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromTokenStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "10", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — feeToCheckFunds (0.006) > nativeBalance (0.002) → NotEnough + val state = result[dexBridgeProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.feeState).isInstanceOf(SwapFeeState.NotEnough::class.java) + } + + @Test + fun `feeToCheckFunds excluding otherNativeFee would have been Enough — pinning the inclusion`() = runTest { + // Given — same shape as above but native balance = 0.002 ETH and otherNativeFee = 0 + // Verifies the contrapositive: with otherNativeFee == 0, balance covers the fee → Enough. + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromTokenStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + amount = BigDecimal("100"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex( + txValue = "1000000000000000", + otherNativeFeeWei = null, + ) + stubExchangeData(dexBridgeProvider, quoteModel, swapData) + + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.002") + + val baseFeeValue = BigDecimal("0.001") + val rawFee: Fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = baseFeeValue, decimals = 18), + ) + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns TransactionFee.Single(normal = rawFee).right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromTokenStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "10", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — without otherNativeFee, the same balance is now sufficient. + val state = result[dexBridgeProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.feeState).isInstanceOf(SwapFeeState.Enough::class.java) + } + + // region — local helpers + + private fun stubExchangeData( + provider: com.tangem.feature.swap.domain.models.domain.SwapProvider, + quoteModel: com.tangem.feature.swap.domain.models.domain.QuoteModel, + swapData: SwapDataModel, + ) { + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = provider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = provider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + } + + private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), + otherNativeFeeWei: BigDecimal? = null, + gas: BigInteger = BigInteger.valueOf(21_000L), + ): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-bridge", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = txData, + otherNativeFeeWei = otherNativeFeeWei, + gas = gas, + ), + ) + + // endregion +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt new file mode 100644 index 0000000000..8cd0721a51 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt @@ -0,0 +1,385 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapFeeState +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.ui.StateBuilder +import com.tangem.utils.Provider +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Characterization tests for `StateBuilder.createFeeState` (private), exercised through the + * public `createQuotesLoadedState`. + * + * Pinned behavior: + * - `TxFeeState.Empty` → `FeeItemState.Empty` + * - `TxFeeState.SingleFeeState` → `FeeItemState.Content` with `isClickable = false` + * - `TxFeeState.MultipleFeeState` + `selectedFeeType = NORMAL` → uses normal fee values, isClickable = true + * - `TxFeeState.MultipleFeeState` + `selectedFeeType = PRIORITY` → uses priority fee values, isClickable = true + * - `hideFee = true` → always `FeeItemState.Empty` regardless of `txFee` + * - `feeCryptoFormattedWithNative` is what populates `FeeItemState.Content.amountCrypto`, + * NOT the plain `feeCryptoFormatted`. Same for fiat. (This pins the bridge-fee + * "display fee with native as workaround for okx" pathway.) + * + * [REDACTED_TASK_KEY] — these exist to guarantee the redesign's `FeeSelectorBlockComponent` carries + * the same display semantics across the cutover. + */ +internal class StateBuilderFeeStateTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + every { isGaslessFeeSupportedForNetwork(any()) } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + ) + } + + @Test + fun `GIVEN TxFeeState Empty WHEN hideFee false THEN fee is FeeItemState Empty`() { + val baseState = buildReadyState() + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.Empty) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN SingleFeeState WHEN hideFee false THEN fee Content is not clickable`() { + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.isClickable).isFalse() + // Field source pinning: amountCrypto/fiatFormatted come from the *WithNative variants. + assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00") + } + + @Test + fun `GIVEN MultipleFeeState WHEN selectedFeeType NORMAL THEN fee Content has normal fee values and is clickable`() { + val baseState = buildReadyState() + val normalFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val priorityFee = buildLegacyFee( + feeType = FeeType.PRIORITY, + cryptoFormatted = "0.005 ETH", + cryptoFormattedWithNative = "0.005 ETH", + fiatFormatted = "$10.00", + fiatFormattedWithNative = "$10.00", + ) + val quoteModel = buildQuoteModel( + txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee), + ) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.isClickable).isTrue() + assertThat(feeContent.feeType).isEqualTo(FeeType.NORMAL) + assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00") + } + + @Test + fun `GIVEN MultipleFeeState WHEN selectedFeeType PRIORITY THEN fee Content has priority fee values and is clickable`() { + val baseState = buildReadyState() + val normalFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val priorityFee = buildLegacyFee( + feeType = FeeType.PRIORITY, + cryptoFormatted = "0.005 ETH", + cryptoFormattedWithNative = "0.005 ETH", + fiatFormatted = "$10.00", + fiatFormattedWithNative = "$10.00", + ) + val quoteModel = buildQuoteModel( + txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee), + ) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.PRIORITY, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.isClickable).isTrue() + assertThat(feeContent.feeType).isEqualTo(FeeType.PRIORITY) + assertThat(feeContent.amountCrypto).isEqualTo("0.005 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$10.00") + } + + @Test + fun `GIVEN hideFee true WHEN any TxFeeState THEN fee is Empty`() { + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.001 ETH", + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$2.00", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = true, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN otherNativeFee greater than feeValue WHEN SingleFeeState THEN amountCrypto reflects the With-Native variant`() { + // Bridge fee scenario: the WithNative formatted strings differ from the plain ones. + // StateBuilder.createFeeState picks `feeCryptoFormattedWithNative` (and fiat) — pinning that. + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.001 ETH", + cryptoFormattedWithNative = "0.006 ETH", // includes 0.005 bridge native fee + fiatFormatted = "$2.00", + fiatFormattedWithNative = "$12.00", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.amountCrypto).isEqualTo("0.006 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$12.00") + } + + @Test + fun `GIVEN otherNativeFee equal to feeValue WHEN SingleFeeState THEN amountCrypto equals plain feeCryptoFormatted`() { + // No bridge fee → WithNative strings happen to equal the plain strings. + val baseState = buildReadyState() + val singleFee = buildLegacyFee( + feeType = FeeType.NORMAL, + cryptoFormatted = "0.0007 ETH", + cryptoFormattedWithNative = "0.0007 ETH", + fiatFormatted = "$1.40", + fiatFormattedWithNative = "$1.40", + ) + val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = buildSwapProvider(), + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as FeeItemState.Content + assertThat(feeContent.amountCrypto).isEqualTo("0.0007 ETH") + assertThat(feeContent.amountFiatFormatted).isEqualTo("$1.40") + } + + // region — local fixtures + + private fun buildReadyState(): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ), + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + private fun buildQuoteModel( + txFeeState: TxFeeState, + ): SwapState.QuotesLoadedState { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val fromInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("1.0"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = fromStatus, + ) + val toInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("0.05"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = toStatus, + ) + return SwapState.QuotesLoadedState( + fromTokenInfo = fromInfo, + toTokenInfo = toInfo, + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isBalanceEnough = true, + feeState = SwapFeeState.Enough, + hasOutgoingTransaction = false, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + ), + permissionState = PermissionDataState.Empty, + txFee = txFeeState, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = buildSwapProvider(), + ) + } + + private fun buildSwapProvider(): SwapProvider = SwapProvider( + providerId = "provider-id", + rateTypes = listOf(RateType.FLOAT), + name = "TestProvider", + type = ExchangeProviderType.DEX, + imageLarge = "https://example.com/icon.png", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, + ) + + private fun buildLegacyFee( + feeType: FeeType, + cryptoFormatted: String, + cryptoFormattedWithNative: String, + fiatFormatted: String, + fiatFormattedWithNative: String, + ): TxFee.Legacy { + val fee: Fee = mockk(relaxed = true) + return TxFee.Legacy( + feeValue = BigDecimal("0.001"), + feeFiatFormatted = fiatFormatted, + feeCryptoFormatted = cryptoFormatted, + feeIncludeOtherNativeFee = BigDecimal.ZERO, + feeFiatFormattedWithNative = fiatFormattedWithNative, + feeCryptoFormattedWithNative = cryptoFormattedWithNative, + cryptoSymbol = "ETH", + feeType = feeType, + fee = fee, + ) + } + + // endregion +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt new file mode 100644 index 0000000000..cb55c0b5a5 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt @@ -0,0 +1,486 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.notifications.NotificationUM +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.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapFeeState +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.feature.swap.model.SwapNotificationsFactory +import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.states.SwapNotificationUM +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Characterization tests for fee-related notifications produced by [SwapNotificationsFactory]. + * + * Pinned behavior: + * - [SwapNotificationUM.Error.UnableToCoverFeeWarning]: + * adds when `feeState = NotEnough` AND `isBalanceEnough = true` AND + * `permissionState != PermissionLoading` AND fee currency != fromCurrency, + * AND NOT (gasless network AND CEX provider). + * Suppressed for CEX-on-gasless-network. Re-added unconditionally when + * `includeFeeInAmount is BalanceNotEnough`. + * - [NotificationUM.Warning.FeeCoverageNotification]: triggers on + * `includeFeeInAmount is Included` AND a fee is selected AND no existential deposit. + * - [SwapNotificationUM.Info.PermissionNeeded]: triggers on `permissionState is PermissionRequired`. + * - [SwapNotificationUM.Error.TransactionInProgressWarning]: triggers on + * `hasOutgoingTransaction = true` (when `permissionState is not PermissionLoading`). + * - `hideFee = true` short-circuits `maybeAddUnableCoverFeeWarning` only. + * + * [REDACTED_TASK_KEY] — these guard the redesign that consolidates fee-state into `SwapFee` / + * `FeeBucket`. Phase 5 will rewrite the factory; these tests stay green throughout. + */ +internal class SwapNotificationsFactoryFeeWarningsTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private val sut: SwapNotificationsFactory by lazy { + SwapNotificationsFactory( + actions = actions, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + ) + } + + private val ethNetworkMock: Network = mockk(relaxed = true) { + every { name } returns "Ethereum" + every { currencySymbol } returns "ETH" + every { rawId } returns "ethereum" + } + private val fromCurrency: CryptoCurrency = mockk(relaxed = true) { + every { network } returns ethNetworkMock + every { symbol } returns "ETH" + every { decimals } returns 18 + every { name } returns "Ethereum" + } + private val differentFeeCurrency: CryptoCurrency = mockk(relaxed = true) { + every { network } returns ethNetworkMock + every { symbol } returns "USDC" + every { decimals } returns 6 + every { name } returns "USD Coin" + } + + // ---------- UnableToCoverFeeWarning ---------- + + @Test + fun `UnableToCoverFeeWarning is added when feeState NotEnough and balance enough and not gasless and fee currency differs`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue() + } + + @Test + fun `UnableToCoverFeeWarning is suppressed when gasless is available for CEX provider`() { + // Given — CEX + supported network → suppressed + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.CEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() + } + + @Test + fun `UnableToCoverFeeWarning is added even when gasless is available IF includeFeeInAmount is BalanceNotEnough`() { + // Given — CEX + supported network BUT BalanceNotEnough overrides the suppression. + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.BalanceNotEnough, + providerType = ExchangeProviderType.CEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue() + } + + @Test + fun `UnableToCoverFeeWarning is not added when hideFee true`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = true, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() + } + + @Test + fun `UnableToCoverFeeWarning is not added when feeState is Enough`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(differentFeeCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() + } + + // ---------- FeeCoverageNotification ---------- + + @Test + fun `FeeCoverageNotification is added when includeFeeInAmount is Included with a selected fee`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val singleFee = buildLegacyFee(FeeType.NORMAL) + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Included(SwapAmount(BigDecimal("0.99"), 18)), + providerType = ExchangeProviderType.CEX, + txFeeState = TxFeeState.SingleFeeState(singleFee), + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isTrue() + } + + @Test + fun `FeeCoverageNotification is not added when includeFeeInAmount is Excluded`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val singleFee = buildLegacyFee(FeeType.NORMAL) + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.CEX, + txFeeState = TxFeeState.SingleFeeState(singleFee), + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isFalse() + } + + // ---------- PermissionNeeded ---------- + + @Test + fun `PermissionNeeded is added when permissionState is PermissionRequired`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.PermissionRequired( + isResetApproval = false, + spenderAddress = "0xSpender", + ), + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isTrue() + } + + @Test + fun `PermissionNeeded is not added when permissionState is Empty`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isFalse() + } + + // ---------- TransactionInProgressWarning / ApprovalInProgressWarning ---------- + + @Test + fun `ApprovalInProgressWarning is added when permissionState is PermissionLoading`() { + // Given — PermissionLoading short-circuits to ApprovalInProgressWarning (an Error subtype) + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.PermissionLoading, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + hasOutgoingTransaction = false, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.ApprovalInProgressWarning }).isTrue() + } + + @Test + fun `TransactionInProgressWarning is added when hasOutgoingTransaction true and permission not loading`() { + // Given + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuoteModel( + feeState = SwapFeeState.Enough, + isBalanceEnough = true, + permissionState = PermissionDataState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType = ExchangeProviderType.DEX, + hasOutgoingTransaction = true, + ) + val feeStatus = buildFeeStatus(fromCurrency) + + // When + val notifications = sut.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeStatus, + selectedFeeType = FeeType.NORMAL, + providerName = "TestProvider", + hideFee = false, + ) + + // Then + assertThat(notifications.any { it is SwapNotificationUM.Error.TransactionInProgressWarning }).isTrue() + } + + // region — local helpers + + @Suppress("LongParameterList") + private fun buildQuoteModel( + feeState: SwapFeeState = SwapFeeState.Enough, + isBalanceEnough: Boolean = true, + permissionState: PermissionDataState = PermissionDataState.Empty, + includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded, + providerType: ExchangeProviderType = ExchangeProviderType.DEX, + txFeeState: TxFeeState = TxFeeState.Empty, + hasOutgoingTransaction: Boolean = false, + ): SwapState.QuotesLoadedState { + val fromStatus = buildSwapCurrencyStatusForFromCurrency() + val toStatus = buildSwapCurrencyStatusForFromCurrency() + val fromInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("1.0"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = fromStatus, + ) + val toInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("0.05"), 18), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = toStatus, + ) + return SwapState.QuotesLoadedState( + fromTokenInfo = fromInfo, + toTokenInfo = toInfo, + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isBalanceEnough = isBalanceEnough, + feeState = feeState, + hasOutgoingTransaction = hasOutgoingTransaction, + includeFeeInAmount = includeFeeInAmount, + ), + permissionState = permissionState, + txFee = txFeeState, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = SwapProvider( + providerId = "p", + rateTypes = listOf(RateType.FLOAT), + name = "TestProvider", + type = providerType, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, + ), + ) + } + + private fun buildSwapCurrencyStatusForFromCurrency(): SwapCurrencyStatus { + val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { amount } returns BigDecimal("1.0") + every { fiatRate } returns BigDecimal("2000.00") + every { fiatAmount } returns BigDecimal("2000.00") + every { networkAddress } returns mockk(relaxed = true) + every { pendingTransactions } returns emptySet() + } + val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = fromCurrency, value = statusValue) + val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns UserWalletId("aabbccdd") + } + val account = com.tangem.domain.models.account.Account.CryptoPortfolio.createMainAccount(userWallet.walletId) + return SwapCurrencyStatus( + userWallet = userWallet, + status = cryptoCurrencyStatus, + account = account, + ) + } + + private fun buildFeeStatus(currency: CryptoCurrency): CryptoCurrencyStatus { + val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { amount } returns BigDecimal("0.5") + } + return CryptoCurrencyStatus(currency = currency, value = statusValue) + } + + private fun buildLegacyFee(feeType: FeeType): TxFee.Legacy { + val fee: Fee = mockk(relaxed = true) + return TxFee.Legacy( + feeValue = BigDecimal("0.001"), + feeFiatFormatted = "$2.00", + feeCryptoFormatted = "0.001 ETH", + feeIncludeOtherNativeFee = BigDecimal.ZERO, + feeFiatFormattedWithNative = "$2.00", + feeCryptoFormattedWithNative = "0.001 ETH", + cryptoSymbol = "ETH", + feeType = feeType, + fee = fee, + ) + } + + // endregion +} \ No newline at end of file From 592f4cf8e7ab7a09f9fe4d3830e7f998d8e529dd Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 23:21:12 +0500 Subject: [PATCH 07/23] Updated on 2026-08-14 --- .../java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt | 2 ++ .../com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt | 1 + .../java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt | 1 + .../com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt | 1 + .../tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt | 1 + .../tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt | 2 ++ 6 files changed, 8 insertions(+) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt index 3de142fe45..a007cd936a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt @@ -1,5 +1,7 @@ package com.tangem.feature.swap.domain.fee +import com.tangem.feature.swap.domain.TransactionFeeResult + /** * Result of calculating the CEX swap transaction fee. * diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt index a352fd73a7..e80a72c653 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt @@ -10,6 +10,7 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal /** diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt index b6d6f767e4..68d2bc0b6b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain.fee +import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal import java.math.BigInteger diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 1509a7338f..16bb399101 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -19,6 +19,7 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index 6d4e06580a..b4d58bbaba 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -16,6 +16,7 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index bbd55adec7..aa834fe764 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -18,8 +18,10 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount From 5e8c334e3dd5e25fe1438fde75b4714d880f4b0c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 23:28:30 +0500 Subject: [PATCH 08/23] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 116 ++++++++++++++++++ .../feature/swap/domain/fee/CexFeeResult.kt | 2 - .../swap/domain/fee/CexSwapFeeCalculator.kt | 1 - .../feature/swap/domain/fee/DexFeeResult.kt | 1 - .../swap/domain/fee/DexSwapFeeCalculator.kt | 1 - .../domain/SwapInteractorImplOnSwapTest.kt | 0 .../domain/fee/CexSwapFeeCalculatorTest.kt | 1 - .../domain/fee/DexSwapFeeCalculatorTest.kt | 1 - ...SwapNotificationsFactoryFeeWarningsTest.kt | 11 -- 9 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt 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 b1b03a0bb9..1f7f07025b 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 @@ -1219,6 +1219,122 @@ internal class SwapInteractorImpl @Inject constructor( } } + /** + * [REDACTED_TASK_KEY] — Phase 3 unified fee API. Delegates to [DexSwapFeeCalculator] / + * [CexSwapFeeCalculator] and wraps the result in a [SwapFee]. + * + * Behavior parity with the legacy `loadFeeForSwapTransaction` overloads is intentional — + * the legacy methods stay in place through Phase 4. See `SwapInteractor.loadSwapFee` for + * the full contract. + */ + @Suppress("LongParameterList", "ReturnCount") + override suspend fun loadSwapFee( + provider: SwapProvider, + fromStatus: SwapCurrencyStatus, + toStatus: SwapCurrencyStatus, + amount: SwapAmount, + swapData: SwapDataModel?, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either = either { + if (amount.value.signum() == 0) { + raise(GetFeeError.UnknownError) + } + return when (provider.type) { + ExchangeProviderType.DEX, + ExchangeProviderType.DEX_BRIDGE, + -> loadDexSwapFee( + fromStatus = fromStatus, + swapData = swapData, + selectedFeeToken = selectedFeeToken, + ) + ExchangeProviderType.CEX -> loadCexSwapFee( + fromStatus = fromStatus, + amount = amount, + selectedFeeToken = selectedFeeToken, + ) + } + } + + /** + * [REDACTED_TASK_KEY] — DEX branch of [loadSwapFee]. Pulls the cached `ExpressTransactionModel.DEX` + * out of [swapData] and hands it to [DexSwapFeeCalculator]. Maps [ExpressDataError] → + * `Left(GetFeeError.UnknownError)` to keep the unified surface a single error type, matching + * what the legacy `loadFeeForSwapTransaction` overload 2 does for DEX failures (line 1027 of + * the original code). + */ + private suspend fun loadDexSwapFee( + fromStatus: SwapCurrencyStatus, + swapData: SwapDataModel?, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either { + val transaction = swapData?.transaction as? ExpressTransactionModel.DEX + ?: return GetFeeError.UnknownError.left() + + return dexSwapFeeCalculator.calculate( + fromSwapCurrencyStatus = fromStatus, + transaction = transaction, + selectedToken = selectedFeeToken, + ).fold( + ifLeft = { GetFeeError.UnknownError.left() }, + ifRight = { dexFeeResult -> + val feeToken = selectedFeeToken + ?: resolveNativeFeeTokenStatus(fromStatus) + ?: return@fold GetFeeError.UnknownError.left() + SwapFeeFactory.from( + transactionFeeResult = dexFeeResult.transactionFee, + selectedFeeToken = feeToken, + otherNativeFee = dexFeeResult.otherNativeFee, + feeBucket = FeeBucket.MARKET, + ).right() + }, + ) + } + + /** + * [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behaviour is preserved: when + * [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator]) + * decides native vs token. The resulting `SwapFee.selectedFeeToken` is the explicit choice + * if provided, otherwise the native coin status of the from-token's network. + */ + private suspend fun loadCexSwapFee( + fromStatus: SwapCurrencyStatus, + amount: SwapAmount, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either { + return cexSwapFeeCalculator.calculate( + userWallet = fromStatus.userWallet, + fromSwapCurrencyStatus = fromStatus, + amount = amount.value, + selectedFeeToken = selectedFeeToken, + ).fold( + ifLeft = { it.left() }, + ifRight = { cexFeeResult -> + val feeToken = selectedFeeToken + ?: resolveNativeFeeTokenStatus(fromStatus) + ?: return@fold GetFeeError.UnknownError.left() + SwapFeeFactory.from( + transactionFeeResult = cexFeeResult.transactionFee, + selectedFeeToken = feeToken, + otherNativeFee = BigDecimal.ZERO, + feeBucket = FeeBucket.MARKET, + ).right() + }, + ) + } + + /** + * [REDACTED_TASK_KEY] — resolves the native-coin [CryptoCurrencyStatus] for the from-token's network. + * Used as the default `selectedFeeToken` of [SwapFee] when the caller did not provide an + * explicit choice. Mirrors how `SwapModel.updateFeePaidCryptoCurrencyFor` populates + * `dataState.feePaidCryptoCurrency`. + */ + private suspend fun resolveNativeFeeTokenStatus(fromStatus: SwapCurrencyStatus): CryptoCurrencyStatus? { + return getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = fromStatus.userWalletId, + cryptoCurrencyStatus = fromStatus.status, + ).getOrNull() + } + private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( userWalletId = swapCurrencyStatus.userWalletId, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt index a007cd936a..3de142fe45 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt @@ -1,7 +1,5 @@ package com.tangem.feature.swap.domain.fee -import com.tangem.feature.swap.domain.TransactionFeeResult - /** * Result of calculating the CEX swap transaction fee. * diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt index e80a72c653..a352fd73a7 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt @@ -10,7 +10,6 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase -import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal /** diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt index 68d2bc0b6b..b6d6f767e4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.domain.fee -import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal import java.math.BigInteger diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 16bb399101..1509a7338f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -19,7 +19,6 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index b4d58bbaba..6d4e06580a 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -16,7 +16,6 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase -import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index aa834fe764..6abb4735b4 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -21,7 +21,6 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt index cb55c0b5a5..02fae0c38b 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt @@ -103,7 +103,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -129,7 +128,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -155,7 +153,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -181,7 +178,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = true, ) @@ -207,7 +203,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -237,7 +232,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -265,7 +259,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -296,7 +289,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -322,7 +314,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -351,7 +342,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) @@ -378,7 +368,6 @@ internal class SwapNotificationsFactoryFeeWarningsTest { quoteModel = quoteModel, feeCryptoCurrencyStatus = feeStatus, selectedFeeType = FeeType.NORMAL, - providerName = "TestProvider", hideFee = false, ) From 9617f52ec9ca83c17796ace75335aa4f2e13b907 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 13:37:43 +0100 Subject: [PATCH 09/23] Updated on 2026-08-14 --- .../component/DefaultWalletBackupComponent.kt | 16 ++++++++++++++++ .../ui/component/GoogleDriveFakeDoorDialog.kt | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt index 5c9921bdf6..c86ca0daee 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.hotwallet.WalletBackupComponent +import com.tangem.features.hotwallet.walletbackup.entity.Action import com.tangem.features.hotwallet.walletbackup.model.WalletBackupModel import com.tangem.features.hotwallet.walletbackup.ui.WalletBackupContent import dagger.assisted.Assisted @@ -26,6 +27,21 @@ internal class DefaultWalletBackupComponent @AssistedInject constructor( WalletBackupContent( state = state, modifier = modifier, + onBackClick = { + model.onAction(Action.OnBack) + }, + onHardwareWalletClick = { + model.onAction(Action.HardwareWallet) + }, + onRecoveryPhraseClick = { + model.onAction(Action.RecoveryPhrase) + }, + onGoogleDriveClick = { + model.onAction(Action.GoogleDriveBackup(isDialogShown = true)) + }, + onGoogleDriveFakeDoorDialogDismiss = { + model.onAction(Action.GoogleDriveBackup(isDialogShown = false)) + }, ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt new file mode 100644 index 0000000000..a986c53471 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt @@ -0,0 +1,17 @@ +package com.tangem.features.hotwallet.walletbackup.ui.component + +import androidx.compose.runtime.Composable +import com.tangem.common.R +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.extensions.stringResourceSafe + +@Composable +fun GoogleDriveFakeDoorDialog(onDismiss: () -> Unit) { + BasicDialog( + title = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_title), + message = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_message), + confirmButton = DialogButtonUM(onClick = onDismiss), + onDismissDialog = onDismiss, + ) +} \ No newline at end of file From 54b9c7f213b4a540bf82de3b9c8b648f853e38c8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 17:08:11 +0100 Subject: [PATCH 10/23] Updated on 2026-08-14 --- .../component/DefaultWalletBackupComponent.kt | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt index c86ca0daee..5c9921bdf6 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/component/DefaultWalletBackupComponent.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.hotwallet.WalletBackupComponent -import com.tangem.features.hotwallet.walletbackup.entity.Action import com.tangem.features.hotwallet.walletbackup.model.WalletBackupModel import com.tangem.features.hotwallet.walletbackup.ui.WalletBackupContent import dagger.assisted.Assisted @@ -27,21 +26,6 @@ internal class DefaultWalletBackupComponent @AssistedInject constructor( WalletBackupContent( state = state, modifier = modifier, - onBackClick = { - model.onAction(Action.OnBack) - }, - onHardwareWalletClick = { - model.onAction(Action.HardwareWallet) - }, - onRecoveryPhraseClick = { - model.onAction(Action.RecoveryPhrase) - }, - onGoogleDriveClick = { - model.onAction(Action.GoogleDriveBackup(isDialogShown = true)) - }, - onGoogleDriveFakeDoorDialogDismiss = { - model.onAction(Action.GoogleDriveBackup(isDialogShown = false)) - }, ) } From c94a86009db8b28a9cc3ff26b8333ca97404915e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 17:48:20 +0100 Subject: [PATCH 11/23] Updated on 2026-08-14 --- .../ui/component/GoogleDriveFakeDoorDialog.kt | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt deleted file mode 100644 index a986c53471..0000000000 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/component/GoogleDriveFakeDoorDialog.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.features.hotwallet.walletbackup.ui.component - -import androidx.compose.runtime.Composable -import com.tangem.common.R -import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButtonUM -import com.tangem.core.ui.extensions.stringResourceSafe - -@Composable -fun GoogleDriveFakeDoorDialog(onDismiss: () -> Unit) { - BasicDialog( - title = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_title), - message = stringResourceSafe(id = R.string.hw_backup_google_drive_dialog_message), - confirmButton = DialogButtonUM(onClick = onDismiss), - onDismissDialog = onDismiss, - ) -} \ No newline at end of file From 24402ef638003c07fccb5a8e3f58cbaee1303853 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 23:21:12 +0500 Subject: [PATCH 12/23] Updated on 2026-08-14 --- .../java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt | 2 ++ .../com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt | 1 + .../java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt | 1 + .../com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt | 1 + .../tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt | 1 + .../tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt | 1 + 6 files changed, 7 insertions(+) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt index 3de142fe45..a007cd936a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt @@ -1,5 +1,7 @@ package com.tangem.feature.swap.domain.fee +import com.tangem.feature.swap.domain.TransactionFeeResult + /** * Result of calculating the CEX swap transaction fee. * diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt index a352fd73a7..e80a72c653 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt @@ -10,6 +10,7 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal /** diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt index b6d6f767e4..68d2bc0b6b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain.fee +import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal import java.math.BigInteger diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 1509a7338f..16bb399101 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -19,6 +19,7 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index 6d4e06580a..b4d58bbaba 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -16,6 +16,7 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 6abb4735b4..aa834fe764 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -21,6 +21,7 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount From 02a0dd59f909e1078873c124075babc3bcd0bf2e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 23:28:30 +0500 Subject: [PATCH 13/23] Updated on 2026-08-14 --- .../java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt | 2 -- .../com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt | 1 - .../java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt | 1 - .../com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt | 1 - .../tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt | 1 - .../tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt | 1 - 6 files changed, 7 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt index a007cd936a..3de142fe45 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt @@ -1,7 +1,5 @@ package com.tangem.feature.swap.domain.fee -import com.tangem.feature.swap.domain.TransactionFeeResult - /** * Result of calculating the CEX swap transaction fee. * diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt index e80a72c653..a352fd73a7 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt @@ -10,7 +10,6 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase -import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal /** diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt index 68d2bc0b6b..b6d6f767e4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.domain.fee -import com.tangem.feature.swap.domain.TransactionFeeResult import java.math.BigDecimal import java.math.BigInteger diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 16bb399101..1509a7338f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -19,7 +19,6 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index b4d58bbaba..6d4e06580a 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -16,7 +16,6 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase -import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import io.mockk.* import kotlinx.coroutines.test.runTest diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index aa834fe764..6abb4735b4 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -21,7 +21,6 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount From 49bef29d23e8458937eb34888c805fc388377d88 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 13 May 2026 01:04:38 +0500 Subject: [PATCH 14/23] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 364 +++++++++++++++++- .../SwapInteractorImplApplySwapFeeTest.kt | 4 + .../SwapInteractorImplFindBestQuoteTest.kt | 2 +- ...pInteractorImplLoadDexSwapDataNoFeeTest.kt | 3 + .../SwapInteractorImplLoadFeeForDexTest.kt | 51 ++- .../SwapInteractorImplOtherNativeFeeTest.kt | 20 +- .../domain/fee/DexSwapFeeCalculatorTest.kt | 1 - .../tangem/feature/swap/model/SwapModel.kt | 30 +- .../feature/swap/StateBuilderFeeStateTest.kt | 19 +- .../intents/WalletWarningsClickIntents.kt | 1 - 10 files changed, 452 insertions(+), 43 deletions(-) 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 1f7f07025b..9de0be4211 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 @@ -600,6 +600,295 @@ internal class SwapInteractorImpl @Inject constructor( } } + @Suppress("NullableToStringCall") + override suspend fun onSwapWithUnifiedFee( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + swapProvider: SwapProvider, + swapData: SwapDataModel?, + amountToSwap: String, + includeFeeInAmount: IncludeFeeInAmount, + fee: SwapFee?, + expressOperationType: ExpressOperationType, + isTangemPayWithdrawal: Boolean, + ): SwapTransactionState { + TangemLogger.i( + """ + Swap (unified fee) + |- swapProvider: $swapProvider + |- swapData: $swapData + |- fromSwapCurrencyStatus: + |---- walletId: ${fromSwapCurrencyStatus.userWalletId} + |---- accountId: ${fromSwapCurrencyStatus.account.accountId} + |---- currencyId: ${fromSwapCurrencyStatus.currency.id} + |- toSwapCurrencyStatus: $toSwapCurrencyStatus + |---- walletId: ${toSwapCurrencyStatus.userWalletId} + |---- accountId: ${toSwapCurrencyStatus.account.accountId} + |---- currencyId: ${toSwapCurrencyStatus.currency.id} + |- amountToSwap: $amountToSwap + |- includeFeeInAmount: $includeFeeInAmount + |- fee: $fee + """.trimIndent(), + shouldSanitize = false, + ) + + val userWallet = fromSwapCurrencyStatus.userWallet + if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { + return SwapTransactionState.DemoMode + } + + return when (swapProvider.type) { + ExchangeProviderType.CEX -> { + val amountDecimal = toBigDecimalOrNull(amountToSwap) + val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals) + val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { + includeFeeInAmount.amountSubtractFee + } else { + amount + } + onSwapCexUnified( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amount = amountToSwapWithFee, + swapFee = fee, + swapProvider = swapProvider, + expressOperationType = expressOperationType, + isTangemPayWithdrawal = isTangemPayWithdrawal, + ) + } + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + val networkId = fromSwapCurrencyStatus.currency.network.rawId + if (isSolana(networkId)) { + onSwapSolanaDex( + provider = swapProvider, + swapData = requireNotNull(swapData), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amountToSwap = amountToSwap, + ) + } else { + if (fee == null) return SwapTransactionState.Error.UnknownError + onSwapDexUnified( + provider = swapProvider, + swapData = requireNotNull(swapData), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + swapFee = fee, + amountToSwap = amountToSwap, + ) + } + } + } + } + + /** + * [REDACTED_TASK_KEY] — Phase 4. DEX swap dispatch using [SwapFee]. Mirrors [onSwapDex] exactly, + * substituting `SwapFee.fee` where the legacy code used `TxFee.fee`. Solana DEX continues + * to use [onSwapSolanaDex] which doesn't consume a fee. + */ + private suspend fun onSwapDexUnified( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + provider: SwapProvider, + swapData: SwapDataModel, + amountToSwap: String, + swapFee: SwapFee, + ): SwapTransactionState { + val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } + val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX + val dataToSign = dexTransaction.txData + val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) + val txData = createTransactionUseCase( + amount = amountToSend, + fee = swapFee.fee, + memo = null, + destination = swapData.transaction.txTo, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = toSwapCurrencyStatus.currency.network, + txExtras = createDexTxExtras( + dataToSign, + fromSwapCurrencyStatus.currency.network, + swapFee.fee.getGasLimit(), + ), + ).getOrElse { error -> + TangemLogger.e("Failed to create swap dex tx data", error) + return SwapTransactionState.Error.UnknownError + } + + return handleSwapResult( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + swapData = swapData, + amount = amount, + txData = txData, + payInAddress = getPayoutAddress(txData), + ) + } + + /** + * [REDACTED_TASK_KEY] — Phase 4. CEX swap dispatch using [SwapFee]. Mirrors [onSwapCex] exactly. + * + * Branch selection (matches legacy): + * - Gasless token path: `swapFee.transactionFeeResult is LoadedExtended && selectedFeeToken.currency is Token` + * → `createAndSendGaslessTransactionUseCase`. + * - Otherwise → `sendTransactionUseCase` with `swapFee.fee`. + */ + @Suppress("LongMethod", "CanBeNonNullable") + private suspend fun onSwapCexUnified( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + amount: SwapAmount, + swapFee: SwapFee?, + swapProvider: SwapProvider, + expressOperationType: ExpressOperationType, + isTangemPayWithdrawal: Boolean, + ): SwapTransactionState { + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress + val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress + val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() + val exchangeData = repository.getExchangeData( + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + fromAddress = fromAddress, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, + providerId = swapProvider.providerId, + rateType = RateType.FLOAT, + expressOperationType = expressOperationType, + toAddress = toAddress, + refundAddress = fromNetworkAddress?.defaultAddress?.value, + refundExtraId = null, // currently always null, + ).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) } + + val exchangeDataCex = + exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError + + if (isTangemPayWithdrawal) { + return SwapTransactionState.TangemPayWithdrawalData( + cryptoAmount = amount.value, + cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId), + cexAddress = exchangeDataCex.txTo, + fromAmount = amountFormatter.formatSwapAmountToUI( + amount, + fromSwapCurrencyStatus.currency.symbol, + ), + fromAmountValue = amount.value, + toAmount = amountFormatter.formatSwapAmountToUI( + exchangeData.toTokenAmount, + toSwapCurrencyStatus.currency.symbol, + ), + toAmountValue = exchangeData.toTokenAmount.value, + storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amount = amount, + swapProvider = swapProvider, + swapDataModel = exchangeData, + txExternalUrl = exchangeDataCex.externalTxUrl, + txExternalId = exchangeDataCex.externalTxId, + averageDuration = null, + ), + exchangeData = TangemPayWithdrawExchangeState( + txId = exchangeDataCex.txId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(), + payInAddress = exchangeData.transaction.txTo, + payInExtraId = exchangeDataCex.txExtraId, + ), + ) + } + + val userWallet = fromSwapCurrencyStatus.userWallet + if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { + return SwapTransactionState.Error.UnknownError + } + val fee = requireNotNull(swapFee) + val txData = createTransferTransactionUseCase( + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), + fee = fee.fee, + memo = exchangeDataCex.txExtraId, + destination = exchangeDataCex.txTo, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromSwapCurrencyStatus.currency.network, + ).getOrElse { error -> + TangemLogger.e("Failed to create swap CEX tx data", error) + return SwapTransactionState.Error.UnknownError + } + + if (txData.extras == null && exchangeDataCex.txExtraId != null) { + return SwapTransactionState.Error.UnknownError + } + + val isGaslessToken = fee.selectedFeeToken.currency is CryptoCurrency.Token && + fee.transactionFeeResult is TransactionFeeResult.LoadedExtended + val result = if (isGaslessToken) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = txData, + userWallet = userWallet, + fee = (fee.transactionFeeResult as TransactionFeeResult.LoadedExtended).fee, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + ) + } + + val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress + val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() + return result.fold( + ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) }, + ifRight = { txHash -> + repository.exchangeSent( + userWallet = userWallet, + txId = exchangeDataCex.txId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + fromAddress = cexFromAddress, + payInAddress = getPayoutAddress(txData), + txHash = txHash, + payInExtraId = exchangeDataCex.txExtraId, + ) + val timestamp = System.currentTimeMillis() + val txExternalUrl = exchangeDataCex.externalTxUrl + storeSwapTransaction( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amount = amount, + swapProvider = swapProvider, + swapDataModel = exchangeData, + timestamp = timestamp, + txExternalUrl = txExternalUrl, + txExternalId = exchangeDataCex.externalTxId, + ) + storeLastCryptoCurrencyId(toSwapCurrencyStatus) + SwapTransactionState.TxSent( + fromAmount = amountFormatter.formatSwapAmountToUI( + amount, + fromSwapCurrencyStatus.currency.symbol, + ), + fromAmountValue = amount.value, + toAmount = amountFormatter.formatSwapAmountToUI( + exchangeData.toTokenAmount, + toSwapCurrencyStatus.currency.symbol, + ), + toAmountValue = exchangeData.toTokenAmount.value, + txHash = txHash, + txExternalUrl = txExternalUrl, + timestamp = timestamp, + ) + }, + ) + } + private suspend fun onSwapDex( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -1291,7 +1580,7 @@ internal class SwapInteractorImpl @Inject constructor( } /** - * [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behaviour is preserved: when + * [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when * [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator]) * decides native vs token. The resulting `SwapFee.selectedFeeToken` is the explicit choice * if provided, otherwise the native coin status of the from-token's network. @@ -1335,6 +1624,79 @@ internal class SwapInteractorImpl @Inject constructor( ).getOrNull() } + /** + * [REDACTED_TASK_KEY] — Phase 4. Patches an existing [SwapState.QuotesLoadedState] with a freshly + * resolved [SwapFee]. See [SwapInteractor.applySwapFee] for the full contract. + * + * Numeric fee used for downstream computation: + * - If `fee.selectedFeeToken.currency` is a token → `0` for the balance/include-fee math when + * the fee currency differs from the from-token (matches legacy `manageWarnings` semantics + * at line 422 of the pre-Phase-4 code). + * - Otherwise → `fee.fee.amount.value + fee.otherNativeFee` (the bridge-aware native fee). + * + * The same `feeToCheck` is fed into `getFeeState`, `isBalanceEnough` and `getIncludeFeeInAmount` + * for consistency with the legacy `loadDexSwapData` path. + */ + override suspend fun applySwapFee( + state: SwapState.QuotesLoadedState, + fee: SwapFee, + lastReducedBalanceBy: BigDecimal, + ): SwapState.QuotesLoadedState { + val fromSwapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus + val amount = state.fromTokenInfo.tokenAmount + val isFeeInToken = fee.selectedFeeToken.currency is CryptoCurrency.Token + val nativeFee = (fee.fee.amount.value ?: BigDecimal.ZERO) + fee.otherNativeFee + + // Mirrors legacy manageWarnings: token-fee paths skip the native deduction. + val warningsFee = if (isFeeInToken && fromSwapCurrencyStatus.currency.id != fee.selectedFeeToken.currency.id) { + BigDecimal.ZERO + } else { + nativeFee + } + + val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + fee = nativeFee, + spendAmount = amount, + selectedFeeToken = fee.selectedFeeToken, + ) + val isBalanceIncludeFeeEnough = isBalanceEnough( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + fee = nativeFee, + ) + val includeFeeInAmount = getIncludeFeeInAmount( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + reduceBalanceBy = lastReducedBalanceBy, + feeValue = nativeFee, + selectedFeeToken = fee.selectedFeeToken, + ) + val currencyCheck = manageWarnings( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + fee = warningsFee, + includeFeeInAmount = includeFeeInAmount, + ) + val validationResult = manageTransactionValidationWarnings( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + feeValue = nativeFee, + ) + val minAdaValue = (fee.fee as? Fee.CardanoToken)?.minAdaValue + + return state.copy( + preparedSwapConfigState = state.preparedSwapConfigState.copy( + isBalanceEnough = isBalanceIncludeFeeEnough, + feeState = feeState, + includeFeeInAmount = includeFeeInAmount, + ), + currencyCheck = currencyCheck, + validationResult = validationResult, + minAdaValue = minAdaValue, + ) + } + private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( userWalletId = swapCurrencyStatus.userWalletId, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt index f608e724fa..9c02543fe9 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt @@ -13,6 +13,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.fee.TransactionFeeResult import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.ui.* @@ -67,6 +68,7 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() } returns Unit.right() coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() } @@ -224,9 +226,11 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() preparedSwapConfigState = PreparedSwapConfigState( balanceStatus = SwapBalanceStatus.Pending, hasOutgoingTransaction = false, + includeFeeInAmount = IncludeFeeInAmount.Excluded, ), permissionState = PermissionDataState.Empty, swapDataModel = null, + txFee = TxFeeState.Empty, currencyCheck = null, validationResult = null, minAdaValue = null, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index f2f4017539..bf926cf0da 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -238,7 +238,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - + ) // Then — has a result entry for the DEX provider; type of state is decided by internal logic diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt index b1af123e7b..05dc76b5b6 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt @@ -12,7 +12,9 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapFeeState import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TxFeeState import io.mockk.coEvery import io.mockk.coVerify import kotlinx.coroutines.test.runTest @@ -74,6 +76,7 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns emptySet() coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() coEvery { getAllowanceInfoUseCase.invoke(any(), any(), any(), any()) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt index b981644028..7fe9e7cf76 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt @@ -27,15 +27,10 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.ui.SwapState -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.mockkStatic -import io.mockk.slot +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import java.math.BigDecimal @@ -56,6 +51,20 @@ import java.math.BigInteger * [REDACTED_TASK_KEY] — these tests are intentionally pinned to the **current** behavior so that the * upcoming refactor (extraction into `DexSwapFeeCalculator`) is provably equivalent. */ +/** + * [REDACTED_TASK_KEY] Phase 4 — `findBestQuote` no longer loads fees. The legacy `loadDexSwapData` is gone, + * replaced by `loadDexSwapDataNoFee` (no fee calls inside). Fee-loading characterization that was + * previously exercised via `findBestQuote` is now covered by: + * - `DexSwapFeeCalculatorTest` — for the raw fee strategy (EVM, Solana, fallback, size guard) + * - `SwapInteractorImplLoadSwapFeeTest` — for the unified entry point through `loadSwapFee` + * - `SwapInteractorImplApplySwapFeeTest` — for fee → state patching semantics + * + * This class is kept in source for reference and disabled. Phase 5 removes it. + */ +@Disabled( + "[REDACTED_TASK_KEY] Phase 4: findBestQuote no longer loads fees. " + + "See DexSwapFeeCalculatorTest, SwapInteractorImplLoadSwapFeeTest, SwapInteractorImplApplySwapFeeTest.", +) @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase() { @@ -195,8 +204,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), - ) + + ) // Then — captured TransactionData carries the values from ExpressTransactionModel.DEX assertThat(capturedTxData.isCaptured).isTrue() @@ -265,8 +274,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), - ) + + ) // Then — native-balance == 0 raises ExpressDataError.UnknownError up to SwapError val state = result[dexProvider] @@ -342,8 +351,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), - ) + + ) // Then — fallback path is invoked with the gas from the express transaction model coVerify(exactly = 1) { @@ -426,8 +435,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), - ) + + ) // Then coVerify(exactly = 1) { @@ -507,8 +516,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), - ) + + ) // Then coVerify(exactly = 1) { @@ -599,8 +608,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), - ) + + ) // Then — TransactionData passed to getFeeUseCase is Compiled (not Uncompiled) assertThat(capturedTxData.isCaptured).isTrue() @@ -678,8 +687,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), - ) + + ) // Then val state = result[dexProvider] diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt index fd66c18baf..93db5c79f7 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt @@ -26,6 +26,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import java.math.BigDecimal @@ -48,6 +49,19 @@ import java.math.BigInteger * [REDACTED_TASK_KEY] — these tests exist to guarantee that the upcoming refactor does not silently * drop the bridge protocol fee for DEX_BRIDGE providers. */ +/** + * [REDACTED_TASK_KEY] Phase 4 — bridge `otherNativeFee` no longer flows through `findBestQuote` (fees aren't + * computed during quotes). The bridge-fee balance check is now exercised by + * `SwapInteractorImplApplySwapFeeTest` where `SwapFee.otherNativeFee` feeds the recomputed + * `feeToCheck = swapFee.fee + otherNativeFee`. The raw propagation from + * `ExpressTransactionModel.DEX.otherNativeFeeWei` is covered by `DexSwapFeeCalculatorTest`. + * + * This class is kept in source for reference and disabled. Phase 5 removes it. + */ +@Disabled( + "[REDACTED_TASK_KEY] Phase 4: otherNativeFee no longer flows through findBestQuote. " + + "See SwapInteractorImplApplySwapFeeTest and DexSwapFeeCalculatorTest.", +) @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase() { @@ -170,7 +184,7 @@ internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase providers = listOf(dexBridgeProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), + ) // Then — the bridge provider produces a QuotesLoadedState (no SwapError) @@ -222,7 +236,7 @@ internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase providers = listOf(dexBridgeProvider), amountToSwap = "10", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), + ) // Then — feeToCheckFunds (0.006) > nativeBalance (0.002) → NotEnough @@ -268,7 +282,7 @@ internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase providers = listOf(dexBridgeProvider), amountToSwap = "10", reduceBalanceBy = BigDecimal.ZERO, - txFeeSealedState = buildTxFeeSealedState(), + ) // Then — without otherNativeFee, the same balance is now sufficient. diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 6abb4735b4..bbd55adec7 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -18,7 +18,6 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase -import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.domain.buildSwapCurrencyStatus 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 629e7eea51..65365603c1 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 @@ -1146,7 +1146,7 @@ internal class SwapModel @Inject constructor( } modelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { - swapInteractor.onSwap( + swapInteractor.onSwapWithUnifiedFee( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, swapProvider = provider, @@ -1989,6 +1989,34 @@ internal class SwapModel @Inject constructor( ) } + /** + * [REDACTED_TASK_KEY] — Phase 4. Extracts the bridge protocol fee from the cached + * [SwapDataModel.transaction] payload (DEX bridge providers carry `otherNativeFeeWei`). + * + * The UI's `FeeSelectorUM` doesn't carry this value, so we read it from the most-recent + * swap data. Returns [BigDecimal.ZERO] when no swap data is cached, the transaction is not + * a DEX payload, or `otherNativeFeeWei` is null (non-bridge providers). + */ + private fun resolveOtherNativeFee(): BigDecimal { + val transaction = + dataState.swapDataModel?.transaction as? ExpressTransactionModel.DEX + ?: return BigDecimal.ZERO + val otherNativeFeeWei = transaction.otherNativeFeeWei ?: return BigDecimal.ZERO + val nativeDecimals = dataState.fromSwapCurrencyStatus?.currency?.network?.let { network -> + Blockchain.fromNetworkId(network.rawId)?.decimals() + } ?: return BigDecimal.ZERO + return otherNativeFeeWei.movePointLeft(nativeDecimals) + } + + private fun com.tangem.features.send.v2.api.entity.FeeItem.toFeeBucket(): FeeBucket = when (this) { + is com.tangem.features.send.v2.api.entity.FeeItem.Slow -> FeeBucket.SLOW + is com.tangem.features.send.v2.api.entity.FeeItem.Market -> FeeBucket.MARKET + is com.tangem.features.send.v2.api.entity.FeeItem.Fast -> FeeBucket.FAST + is com.tangem.features.send.v2.api.entity.FeeItem.Suggested -> FeeBucket.SUGGESTED + is com.tangem.features.send.v2.api.entity.FeeItem.Custom -> FeeBucket.CUSTOM + is com.tangem.features.send.v2.api.entity.FeeItem.Loading -> FeeBucket.MARKET + } + /** * [REDACTED_TASK_KEY] — Phase 4. Extracts the bridge protocol fee from the cached * [SwapDataModel.transaction] payload (DEX bridge providers carry `otherNativeFeeWei`). diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt index 8cd0721a51..7af4b86dd9 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt @@ -2,24 +2,14 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.transaction.Fee +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType -import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState -import com.tangem.feature.swap.domain.models.domain.RateType -import com.tangem.feature.swap.domain.models.domain.SwapFeeState -import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.ui.FeeType -import com.tangem.feature.swap.domain.models.ui.PermissionDataState -import com.tangem.feature.swap.domain.models.ui.PriceImpact -import com.tangem.feature.swap.domain.models.ui.SwapState -import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo -import com.tangem.feature.swap.domain.models.ui.TxFee -import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.FeeItemState @@ -76,6 +66,7 @@ internal class StateBuilderFeeStateTest { appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + shouldShowAbMenu = false, ) } @@ -306,7 +297,7 @@ internal class StateBuilderFeeStateTest { return sut.createInitialReadyState( uiStateHolder = sut.createInitialLoadingState(), emptyAmountState = SwapState.EmptyAmountState( - zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + zeroAmountEquivalent = stringReference("$0.00"), ), fromSwapCurrencyStatus = fromStatus, toSwapCurrencyStatus = toStatus, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 38ab1f0619..9d839be728 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -7,7 +7,6 @@ import com.tangem.common.ui.notifications.NotificationId import com.tangem.common.ui.userwallet.handle import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic.ButtonSupport import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped From b646b4f01080aa5a16435652878d4fd80b9f7dbb Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 14:22:23 +0500 Subject: [PATCH 15/23] Updated on 2026-08-14 --- .../SwapInteractorImplLoadFeeForDexTest.kt | 745 ------------------ .../SwapInteractorImplOtherNativeFeeTest.kt | 359 --------- .../feature/swap/StateBuilderFeeStateTest.kt | 376 --------- ...SwapNotificationsFactoryFeeWarningsTest.kt | 475 ----------- 4 files changed, 1955 deletions(-) delete mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt delete mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt delete mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt delete mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt deleted file mode 100644 index 7fe9e7cf76..0000000000 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeForDexTest.kt +++ /dev/null @@ -1,745 +0,0 @@ -package com.tangem.feature.swap.domain - -import android.util.Base64 -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionExtras -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.quote.QuoteStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.swap.models.SwapCurrencyStatus -import com.tangem.domain.tokens.model.FeePaidCurrency -import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck -import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.domain.transaction.models.AllowanceInfo -import com.tangem.feature.swap.domain.models.ExpressDataError -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType -import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.ui.SwapState -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import java.math.BigDecimal -import java.math.BigInteger - -/** - * Characterization tests for the private fee-loading paths of [SwapInteractorImpl] reached by - * the DEX provider branch: - * - * - `loadFeeForDex` - * - `getFeeDataForDexSwap` (EVM) - * - `getFeeDataForSolanaDexSwap` (Solana) - * - the `patchTransactionFeeForSwap` 12% gas-limit bump applied on EVM DEX - * - * Driven through the public [SwapInteractorImpl.findBestQuote] entry point with carefully - * stubbed dependencies so the DEX-fee branch executes deterministically. - * - * [REDACTED_TASK_KEY] — these tests are intentionally pinned to the **current** behavior so that the - * upcoming refactor (extraction into `DexSwapFeeCalculator`) is provably equivalent. - */ -/** - * [REDACTED_TASK_KEY] Phase 4 — `findBestQuote` no longer loads fees. The legacy `loadDexSwapData` is gone, - * replaced by `loadDexSwapDataNoFee` (no fee calls inside). Fee-loading characterization that was - * previously exercised via `findBestQuote` is now covered by: - * - `DexSwapFeeCalculatorTest` — for the raw fee strategy (EVM, Solana, fallback, size guard) - * - `SwapInteractorImplLoadSwapFeeTest` — for the unified entry point through `loadSwapFee` - * - `SwapInteractorImplApplySwapFeeTest` — for fee → state patching semantics - * - * This class is kept in source for reference and disabled. Phase 5 removes it. - */ -@Disabled( - "[REDACTED_TASK_KEY] Phase 4: findBestQuote no longer loads fees. " + - "See DexSwapFeeCalculatorTest, SwapInteractorImplLoadSwapFeeTest, SwapInteractorImplApplySwapFeeTest.", -) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase() { - - private val ethNetwork = Blockchain.Ethereum.toNetworkId() - private val solanaNetwork = Blockchain.Solana.toNetworkId() - private val btcNetwork = Blockchain.Bitcoin.toNetworkId() - - @BeforeEach - fun setup() { - coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin - coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") - coEvery { - getCurrencyCheckUseCase.invoke( - userWalletId = any(), - currencyStatus = any(), - feeCurrencyStatus = any(), - amount = any(), - fee = any(), - feeCurrencyBalanceAfterTransaction = any(), - recipientAddress = any(), - ) - } returns CryptoCurrencyCheck( - dustValue = null, - reserveAmount = null, - minimumSendAmount = null, - existentialDeposit = null, - utxoAmountLimit = null, - isAccountFunded = true, - rentWarning = null, - isMemoRequired = false, - ) - coEvery { - validateTransactionUseCase.invoke( - amount = any(), - fee = any(), - memo = any(), - destination = any(), - userWalletId = any(), - network = any(), - ) - } returns Unit.right() - coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { - firstArg>().map { rawId -> - QuoteStatus( - rawCurrencyId = rawId, - value = QuoteStatus.Data( - source = StatusSource.ACTUAL, - fiatRate = BigDecimal.ONE, - fiatRateUSD = BigDecimal.ONE, - priceChange = BigDecimal.ZERO, - ), - ) - }.toSet() - } - coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() - coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() - coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null - coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() - every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false - coEvery { - getAllowanceInfoUseCase.invoke( - userWalletId = any(), - cryptoCurrency = any(), - spenderAddress = any(), - requiredAmount = any(), - ) - } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() - every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns - mockk(relaxed = true).right() - } - - @Test - fun `EVM DEX swap propagates extras destinationAddress sourceAddress and amount to getFeeUseCase`() = runTest { - // Given - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - isCoin = true, - amount = BigDecimal("10"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val swapData = buildSwapDataModelDex( - txValue = "1000000000000000", // 0.001 ETH - txTo = "0xRecipient", - txFrom = "0xSender", - txData = "0xPayload", - ) - - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - - val capturedTxData = slot() - coEvery { - getFeeUseCase.invoke( - userWallet = any(), - network = any(), - transactionData = capture(capturedTxData), - ) - } returns mockk(relaxed = true).right() - - // When - sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then — captured TransactionData carries the values from ExpressTransactionModel.DEX - assertThat(capturedTxData.isCaptured).isTrue() - val uncompiled = capturedTxData.captured as TransactionData.Uncompiled - assertThat(uncompiled.destinationAddress).isEqualTo("0xRecipient") - assertThat(uncompiled.sourceAddress).isEqualTo("0xSender") - // amount.value is the txValue moved-point-left by native decimals (18 for ETH) → 0.001 - // Use compareTo-equivalence to ignore the BigDecimal scale (0.001 vs 0.001000000000000000). - assertThat(uncompiled.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.001")) - // extras came from createTransactionExtrasUseCase - assertThat(uncompiled.extras).isNotNull() - } - - @Test - fun `EVM DEX swap with native balance ZERO surfaces SwapError UnknownError`() = runTest { - // Given — zero native balance triggers the early-raise in getFeeDataForDexSwap - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - isCoin = true, - amount = BigDecimal("10"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val swapData = buildSwapDataModelDex(txValue = "0") - - coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO - - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - - // When - val result = sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then — native-balance == 0 raises ExpressDataError.UnknownError up to SwapError - val state = result[dexProvider] - assertThat(state).isInstanceOf(SwapState.SwapError::class.java) - val swapError = state as SwapState.SwapError - assertThat(swapError.error).isEqualTo(ExpressDataError.UnknownError) - // getFeeUseCase should NOT have been invoked because the balance check short-circuits first - coVerify(exactly = 0) { - getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) - } - } - - @Test - fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when txValue is null`() = runTest { - // Given — null txValue forces error("unable to get txValue") → IllegalStateException → fallback - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - isCoin = true, - amount = BigDecimal("10"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val gas = BigInteger.valueOf(150_000L) - val swapData = buildSwapDataModelDex(txValue = null, gas = gas) - - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - - coEvery { - getEthSpecificFeeUseCase.invoke( - userWallet = any(), - cryptoCurrency = any(), - gasLimit = any(), - gasPrice = any(), - ) - } returns mockk(relaxed = true).right() - - // When - sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then — fallback path is invoked with the gas from the express transaction model - coVerify(exactly = 1) { - getEthSpecificFeeUseCase.invoke( - userWallet = any(), - cryptoCurrency = any(), - gasLimit = gas, - gasPrice = any(), - ) - } - } - - @Test - fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when createTransactionExtrasUseCase returns null`() = - runTest { - // Given — null extras → error("unable to create extras") → IllegalStateException → fallback - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - isCoin = true, - amount = BigDecimal("10"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val gas = BigInteger.valueOf(75_000L) - val swapData = buildSwapDataModelDex(txValue = "1000000000000000", gas = gas) - - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - - // Force createTransactionExtrasUseCase to return null → triggers the fallback path. - // The use case signature is Either; pass a Throwable Left. - every { - createTransactionExtrasUseCase.invoke(data = any(), network = any()) - } returns IllegalStateException("forced fail").left() - - coEvery { - getEthSpecificFeeUseCase.invoke( - userWallet = any(), - cryptoCurrency = any(), - gasLimit = any(), - gasPrice = any(), - ) - } returns mockk(relaxed = true).right() - - // When - sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then - coVerify(exactly = 1) { - getEthSpecificFeeUseCase.invoke( - userWallet = any(), - cryptoCurrency = any(), - gasLimit = gas, - gasPrice = any(), - ) - } - } - - @Test - fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when getFeeUseCase returns null`() = runTest { - // Given — getFeeUseCase Left → getOrNull() == null → error("unable to calculate fee") → fallback - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - isCoin = true, - amount = BigDecimal("10"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val gas = BigInteger.valueOf(50_000L) - val swapData = buildSwapDataModelDex(txValue = "1000000000000000", gas = gas) - - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - - coEvery { - getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) - } returns GetFeeError.UnknownError.left() - - coEvery { - getEthSpecificFeeUseCase.invoke( - userWallet = any(), - cryptoCurrency = any(), - gasLimit = any(), - gasPrice = any(), - ) - } returns mockk(relaxed = true).right() - - // When - sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then - coVerify(exactly = 1) { - getEthSpecificFeeUseCase.invoke( - userWallet = any(), - cryptoCurrency = any(), - gasLimit = gas, - gasPrice = any(), - ) - } - } - - @Test - fun `Solana DEX uses TransactionData Compiled and skips the 12 percent gas patch`() = runTest { - // Given — Solana network forces the Compiled path. We capture the TransactionData and - // assert that the resulting fee value is the raw return of getFeeUseCase (no 1.12x scaling). - mockkStatic(Base64::class) - every { Base64.decode(any(), any()) } returns ByteArray(64) - mockkObject(SolanaTransactionHelper) - every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns ByteArray(64) - - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = solanaNetwork, - isCoin = true, - amount = BigDecimal("10"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) - val quoteModel = buildQuoteModel() - val swapData = buildSwapDataModelDex(txData = "U29sYW5h") - - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - - // Construct a deterministic Solana fee — Fee.Common with a known amount value. - val rawFeeAmount = BigDecimal("0.005000") - val rawFee: Fee = Fee.Common( - amount = Amount( - currencySymbol = "SOL", - value = rawFeeAmount, - decimals = 9, - ), - ) - val txFee = TransactionFee.Single(normal = rawFee) - val capturedTxData = slot() - coEvery { - getFeeUseCase.invoke( - userWallet = any(), - network = any(), - transactionData = capture(capturedTxData), - ) - } returns txFee.right() - - // When - sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then — TransactionData passed to getFeeUseCase is Compiled (not Uncompiled) - assertThat(capturedTxData.isCaptured).isTrue() - assertThat(capturedTxData.captured).isInstanceOf(TransactionData.Compiled::class.java) - // No gas-patch is applied on the Solana path; the raw amount is preserved. - // Pinning behavior: Fee.Common is not a Fee.Ethereum, so increaseEthGasLimitInNeeded - // returns it unchanged → no 1.12x scaling. - assertThat(rawFee.amount.value).isEqualTo(rawFeeAmount) - } - - @Test - fun `Solana DEX size guard raises TooLargeSolanaTransactionError when formatted hash exceeds 1232 bytes on Cold wallet`() = - runTest { - // Given — formatted hash > 1232 bytes on a Cold wallet → SwapError(TooLargeSolanaTransactionError) - mockkStatic(Base64::class) - val oversizedBytes = ByteArray(1300) - every { Base64.decode(any(), any()) } returns oversizedBytes - mockkObject(SolanaTransactionHelper) - every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns oversizedBytes - - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val coldWallet = mockk(relaxed = true) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = solanaNetwork, - isCoin = true, - amount = BigDecimal("10"), - ).let { status -> - SwapCurrencyStatus( - userWallet = coldWallet, - status = status.status, - account = status.account, - ) - } - val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) - val quoteModel = buildQuoteModel() - val swapData = buildSwapDataModelDex(txData = "very-long-base64-content==") - - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = dexProvider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - - // When - val result = sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then - val state = result[dexProvider] - assertThat(state).isInstanceOf(SwapState.SwapError::class.java) - val swapError = state as SwapState.SwapError - assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError) - } - - /** - * Surprising current behavior pinned here for the redesign: - * - * In `loadDexSwapData`, the local `txFeeState` produced by `loadFeeForDex(...).toTxFeeState(...)` - * is computed but NEVER PROPAGATED to `QuotesLoadedState.txFee`. The latter is sourced from - * the input `txFeeSealedState` parameter via `updateBalances`. This means the 12% gas patch - * is applied (the side-effect runs) but the patched value is then discarded for state - * purposes; only the side effects of `loadFeeForDex` (raising on Solana size limit, balance=0, - * etc.) survive. - * - * The 12% gas-patch math itself is fully covered by the planned Phase-2 PatchEthGasLimitForSwapTest; - * pinning it through the public API here would only assert the discarded result. - * - * [REDACTED_TASK_KEY] — flagged for Phase-2 author awareness; the refactor MUST decide whether to: - * (a) preserve the dead-store (unlikely), or - * (b) actually wire the loaded fee into the resulting state (the intended fix). - */ - - // region — local builders - - private fun buildSwapDataModelDex( - txData: String = "dGVzdA==", - txValue: String? = "0", - toAmount: BigDecimal = BigDecimal("0.5"), - otherNativeFeeWei: BigDecimal? = null, - gas: BigInteger = BigInteger.valueOf(21_000L), - txTo: String = "0xRecipient", - txFrom: String = "0xSender", - ): SwapDataModel = SwapDataModel( - toTokenAmount = SwapAmount(toAmount, 18), - transaction = ExpressTransactionModel.DEX( - fromAmount = SwapAmount(BigDecimal.ONE, 18), - toAmount = SwapAmount(toAmount, 18), - txValue = txValue, - txId = "tx-id-123", - txTo = txTo, - txExtraId = null, - txFrom = txFrom, - txData = txData, - otherNativeFeeWei = otherNativeFeeWei, - gas = gas, - ), - ) - - // endregion -} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt deleted file mode 100644 index 93db5c79f7..0000000000 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOtherNativeFeeTest.kt +++ /dev/null @@ -1,359 +0,0 @@ -package com.tangem.feature.swap.domain - -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.TransactionExtras -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.quote.QuoteStatus -import com.tangem.domain.tokens.model.FeePaidCurrency -import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck -import com.tangem.domain.transaction.models.AllowanceInfo -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType -import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.domain.SwapFeeState -import com.tangem.feature.swap.domain.models.ui.SwapState -import com.tangem.feature.swap.domain.models.ui.TxFeeState -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import java.math.BigDecimal -import java.math.BigInteger - -/** - * Characterization tests for the bridge-fee field `otherNativeFeeWei` flowing into the - * resulting [TxFeeState]. - * - * Pinned behavior: - * - `otherNativeFee` (BigDecimal) = `transaction.otherNativeFeeWei` shifted left by native - * decimals (18 for ETH). - * - `feeIncludeOtherNativeFee` of the resulting `TxFee.Legacy` equals `feeValue + otherNativeFee`. - * - When `otherNativeFeeWei == null`, `feeIncludeOtherNativeFee == feeValue`. - * - The `feeToCheckFunds` (the value used by `getFeeState`) equals - * `feeByPriority + otherNativeFee`. We assert this indirectly: when the native balance is - * BETWEEN `feeByPriority` and `feeByPriority + otherNativeFee`, the resulting - * `SwapFeeState` is `NotEnough` (not `Enough`). - * - * [REDACTED_TASK_KEY] — these tests exist to guarantee that the upcoming refactor does not silently - * drop the bridge protocol fee for DEX_BRIDGE providers. - */ -/** - * [REDACTED_TASK_KEY] Phase 4 — bridge `otherNativeFee` no longer flows through `findBestQuote` (fees aren't - * computed during quotes). The bridge-fee balance check is now exercised by - * `SwapInteractorImplApplySwapFeeTest` where `SwapFee.otherNativeFee` feeds the recomputed - * `feeToCheck = swapFee.fee + otherNativeFee`. The raw propagation from - * `ExpressTransactionModel.DEX.otherNativeFeeWei` is covered by `DexSwapFeeCalculatorTest`. - * - * This class is kept in source for reference and disabled. Phase 5 removes it. - */ -@Disabled( - "[REDACTED_TASK_KEY] Phase 4: otherNativeFee no longer flows through findBestQuote. " + - "See SwapInteractorImplApplySwapFeeTest and DexSwapFeeCalculatorTest.", -) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase() { - - private val ethNetwork = Blockchain.Ethereum.toNetworkId() - private val btcNetwork = Blockchain.Bitcoin.toNetworkId() - - @BeforeEach - fun setup() { - coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin - // Default native balance is large; specific tests override. - coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") - coEvery { - getCurrencyCheckUseCase.invoke( - userWalletId = any(), - currencyStatus = any(), - feeCurrencyStatus = any(), - amount = any(), - fee = any(), - feeCurrencyBalanceAfterTransaction = any(), - recipientAddress = any(), - ) - } returns CryptoCurrencyCheck( - dustValue = null, - reserveAmount = null, - minimumSendAmount = null, - existentialDeposit = null, - utxoAmountLimit = null, - isAccountFunded = true, - rentWarning = null, - isMemoRequired = false, - ) - coEvery { - validateTransactionUseCase.invoke( - amount = any(), - fee = any(), - memo = any(), - destination = any(), - userWalletId = any(), - network = any(), - ) - } returns Unit.right() - coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { - firstArg>().map { rawId -> - QuoteStatus( - rawCurrencyId = rawId, - value = QuoteStatus.Data( - source = StatusSource.ACTUAL, - fiatRate = BigDecimal.ONE, - fiatRateUSD = BigDecimal.ONE, - priceChange = BigDecimal.ZERO, - ), - ) - }.toSet() - } - coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() - coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() - coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null - coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() - every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false - coEvery { - getAllowanceInfoUseCase.invoke( - userWalletId = any(), - cryptoCurrency = any(), - spenderAddress = any(), - requiredAmount = any(), - ) - } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() - every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns - mockk(relaxed = true).right() - } - - /** - * Surprising current behavior (caught while writing this test): - * - * In `loadDexSwapData`, the local `txFeeState` produced by `loadFeeForDex(...).toTxFeeState(...)` - * is computed but **never used** for the resulting `QuotesLoadedState.txFee`. The actual - * `txFee` field of the resulting state is populated from the input `txFeeSealedState` - * parameter via `updateBalances` → which means the `feeIncludeOtherNativeFee` etc. on - * the returned state come from whatever the caller passes in, NOT from the loaded fee. - * - * What IS observable through the public API: - * - `feeByPriority + otherNativeFee` enters `feeToCheckFunds` and drives `feeState` - * (Enough vs NotEnough). This is verified in the two tests below. - * - * The "feeIncludeOtherNativeFee on the result.txFee" assertion is intentionally NOT - * pinned here — that field is sourced from the caller's `txFeeSealedState` and a refactor - * that fixes this dead-store will not break this test class. - * - * [REDACTED_TASK_KEY] — flagged for discussion before Phase 2. - */ - - @Test - fun `bridge provider with non-zero otherNativeFeeWei loads exchange data and reaches getFeeUseCase`() = runTest { - // Given — DEX_BRIDGE with otherNativeFeeWei = 5e15 wei = 0.005 ETH - val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) - val fromStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - isCoin = true, - amount = BigDecimal("10"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val swapData = buildSwapDataModelDex( - txValue = "1000000000000000", - otherNativeFeeWei = BigDecimal("5000000000000000"), - ) - stubExchangeData(dexBridgeProvider, quoteModel, swapData) - - val rawFee: Fee = Fee.Common( - amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18), - ) - coEvery { - getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) - } returns TransactionFee.Single(normal = rawFee).right() - - // When - val result = sut.findBestQuote( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexBridgeProvider), - amountToSwap = "1.0", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then — the bridge provider produces a QuotesLoadedState (no SwapError) - // and the swap data carries the otherNativeFeeWei. - val state = result[dexBridgeProvider] - assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) - val loaded = state as SwapState.QuotesLoadedState - val transaction = loaded.swapDataModel?.transaction as? ExpressTransactionModel.DEX - assertThat(transaction?.otherNativeFeeWei).isEqualTo(BigDecimal("5000000000000000")) - } - - @Test - fun `feeToCheckFunds includes otherNativeFee — NotEnough fires when balance covers fee but not fee plus otherNativeFee`() = - runTest { - // Given — DEX_BRIDGE swap, native balance = 0.002 ETH - // base fee = 0.001 ETH, otherNativeFee = 0.005 ETH → feeToCheck = 0.006 ETH > balance - // For a Coin swap the feeState branch checks: nativeBalance - spendAmount > fee - // We swap a Token (so fromToken != Coin) → branch becomes: nativeBalance > fee - val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) - val fromTokenStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - contractAddress = "0xToken", - isCoin = false, - amount = BigDecimal("100"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val swapData = buildSwapDataModelDex( - txValue = "1000000000000000", - otherNativeFeeWei = BigDecimal("5000000000000000"), // 0.005 ETH - ) - stubExchangeData(dexBridgeProvider, quoteModel, swapData) - - // Native balance: 0.002 ETH — enough for base fee (0.001) but NOT for combined (0.006). - coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.002") - - val baseFeeValue = BigDecimal("0.001") - val rawFee: Fee = Fee.Common( - amount = Amount(currencySymbol = "ETH", value = baseFeeValue, decimals = 18), - ) - coEvery { - getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) - } returns TransactionFee.Single(normal = rawFee).right() - - // When - val result = sut.findBestQuote( - fromSwapCurrencyStatus = fromTokenStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexBridgeProvider), - amountToSwap = "10", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then — feeToCheckFunds (0.006) > nativeBalance (0.002) → NotEnough - val state = result[dexBridgeProvider] - assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) - val loaded = state as SwapState.QuotesLoadedState - assertThat(loaded.preparedSwapConfigState.feeState).isInstanceOf(SwapFeeState.NotEnough::class.java) - } - - @Test - fun `feeToCheckFunds excluding otherNativeFee would have been Enough — pinning the inclusion`() = runTest { - // Given — same shape as above but native balance = 0.002 ETH and otherNativeFee = 0 - // Verifies the contrapositive: with otherNativeFee == 0, balance covers the fee → Enough. - val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) - val fromTokenStatus = buildSwapCurrencyStatus( - networkRawId = ethNetwork, - contractAddress = "0xToken", - isCoin = false, - amount = BigDecimal("100"), - ) - val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) - val quoteModel = buildQuoteModel() - val swapData = buildSwapDataModelDex( - txValue = "1000000000000000", - otherNativeFeeWei = null, - ) - stubExchangeData(dexBridgeProvider, quoteModel, swapData) - - coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.002") - - val baseFeeValue = BigDecimal("0.001") - val rawFee: Fee = Fee.Common( - amount = Amount(currencySymbol = "ETH", value = baseFeeValue, decimals = 18), - ) - coEvery { - getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) - } returns TransactionFee.Single(normal = rawFee).right() - - // When - val result = sut.findBestQuote( - fromSwapCurrencyStatus = fromTokenStatus, - toSwapCurrencyStatus = toStatus, - providers = listOf(dexBridgeProvider), - amountToSwap = "10", - reduceBalanceBy = BigDecimal.ZERO, - - ) - - // Then — without otherNativeFee, the same balance is now sufficient. - val state = result[dexBridgeProvider] - assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) - val loaded = state as SwapState.QuotesLoadedState - assertThat(loaded.preparedSwapConfigState.feeState).isInstanceOf(SwapFeeState.Enough::class.java) - } - - // region — local helpers - - private fun stubExchangeData( - provider: com.tangem.feature.swap.domain.models.domain.SwapProvider, - quoteModel: com.tangem.feature.swap.domain.models.domain.QuoteModel, - swapData: SwapDataModel, - ) { - coEvery { - repository.findBestQuote( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = provider.providerId, - rateType = any(), - ) - } returns quoteModel.right() - coEvery { - repository.getExchangeData( - userWallet = any(), - fromContractAddress = any(), - fromNetwork = any(), - toContractAddress = any(), - fromAddress = any(), - toNetwork = any(), - fromAmount = any(), - fromDecimals = any(), - toDecimals = any(), - providerId = provider.providerId, - rateType = any(), - toAddress = any(), - expressOperationType = any(), - refundAddress = any(), - ) - } returns swapData.right() - } - - private fun buildSwapDataModelDex( - txData: String = "dGVzdA==", - txValue: String? = "0", - toAmount: BigDecimal = BigDecimal("0.5"), - otherNativeFeeWei: BigDecimal? = null, - gas: BigInteger = BigInteger.valueOf(21_000L), - ): SwapDataModel = SwapDataModel( - toTokenAmount = SwapAmount(toAmount, 18), - transaction = ExpressTransactionModel.DEX( - fromAmount = SwapAmount(BigDecimal.ONE, 18), - toAmount = SwapAmount(toAmount, 18), - txValue = txValue, - txId = "tx-id-bridge", - txTo = "0xRecipient", - txExtraId = null, - txFrom = "0xSender", - txData = txData, - otherNativeFeeWei = otherNativeFeeWei, - gas = gas, - ), - ) - - // endregion -} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt deleted file mode 100644 index 7af4b86dd9..0000000000 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderFeeStateTest.kt +++ /dev/null @@ -1,376 +0,0 @@ -package com.tangem.feature.swap - -import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.* -import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.feature.swap.models.SwapStateHolder -import com.tangem.feature.swap.models.UiActions -import com.tangem.feature.swap.models.states.FeeItemState -import com.tangem.feature.swap.ui.StateBuilder -import com.tangem.utils.Provider -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.math.BigDecimal - -/** - * Characterization tests for `StateBuilder.createFeeState` (private), exercised through the - * public `createQuotesLoadedState`. - * - * Pinned behavior: - * - `TxFeeState.Empty` → `FeeItemState.Empty` - * - `TxFeeState.SingleFeeState` → `FeeItemState.Content` with `isClickable = false` - * - `TxFeeState.MultipleFeeState` + `selectedFeeType = NORMAL` → uses normal fee values, isClickable = true - * - `TxFeeState.MultipleFeeState` + `selectedFeeType = PRIORITY` → uses priority fee values, isClickable = true - * - `hideFee = true` → always `FeeItemState.Empty` regardless of `txFee` - * - `feeCryptoFormattedWithNative` is what populates `FeeItemState.Content.amountCrypto`, - * NOT the plain `feeCryptoFormatted`. Same for fiat. (This pins the bridge-fee - * "display fee with native as workaround for okx" pathway.) - * - * [REDACTED_TASK_KEY] — these exist to guarantee the redesign's `FeeSelectorBlockComponent` carries - * the same display semantics across the cutover. - */ -internal class StateBuilderFeeStateTest { - - private val actions: UiActions = mockk(relaxed = true) - private val isBalanceHiddenProvider: Provider = mockk() - private val appCurrencyProvider: Provider = mockk() - private val isAccountsModeProvider: Provider = mockk() - private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() - - private lateinit var sut: StateBuilder - - private val userWalletId = UserWalletId("aabbccdd") - private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { - every { walletId } returns userWalletId - } - - @BeforeEach - fun setup() { - every { isBalanceHiddenProvider() } returns false - every { appCurrencyProvider() } returns AppCurrency.Default - every { isAccountsModeProvider() } returns false - every { isGaslessFeeSupportedForNetwork(any()) } returns false - - sut = StateBuilder( - actions = actions, - isBalanceHiddenProvider = isBalanceHiddenProvider, - appCurrencyProvider = appCurrencyProvider, - isAccountsModeProvider = isAccountsModeProvider, - isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, - shouldShowAbMenu = false, - ) - } - - @Test - fun `GIVEN TxFeeState Empty WHEN hideFee false THEN fee is FeeItemState Empty`() { - val baseState = buildReadyState() - val quoteModel = buildQuoteModel(txFeeState = TxFeeState.Empty) - - val result = sut.createQuotesLoadedState( - uiStateHolder = baseState, - quoteModel = quoteModel, - feeCryptoCurrencyStatus = null, - swapProvider = buildSwapProvider(), - bestRatedProviderId = "provider-id", - isNeedBestRateBadge = false, - selectedFeeType = FeeType.NORMAL, - needApplyFCARestrictions = false, - hideFee = false, - ) - - assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) - } - - @Test - fun `GIVEN SingleFeeState WHEN hideFee false THEN fee Content is not clickable`() { - val baseState = buildReadyState() - val singleFee = buildLegacyFee( - feeType = FeeType.NORMAL, - cryptoFormatted = "0.001 ETH", - cryptoFormattedWithNative = "0.001 ETH", - fiatFormatted = "$2.00", - fiatFormattedWithNative = "$2.00", - ) - val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) - - val result = sut.createQuotesLoadedState( - uiStateHolder = baseState, - quoteModel = quoteModel, - feeCryptoCurrencyStatus = null, - swapProvider = buildSwapProvider(), - bestRatedProviderId = "provider-id", - isNeedBestRateBadge = false, - selectedFeeType = FeeType.NORMAL, - needApplyFCARestrictions = false, - hideFee = false, - ) - - val feeContent = result.fee as FeeItemState.Content - assertThat(feeContent.isClickable).isFalse() - // Field source pinning: amountCrypto/fiatFormatted come from the *WithNative variants. - assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH") - assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00") - } - - @Test - fun `GIVEN MultipleFeeState WHEN selectedFeeType NORMAL THEN fee Content has normal fee values and is clickable`() { - val baseState = buildReadyState() - val normalFee = buildLegacyFee( - feeType = FeeType.NORMAL, - cryptoFormatted = "0.001 ETH", - cryptoFormattedWithNative = "0.001 ETH", - fiatFormatted = "$2.00", - fiatFormattedWithNative = "$2.00", - ) - val priorityFee = buildLegacyFee( - feeType = FeeType.PRIORITY, - cryptoFormatted = "0.005 ETH", - cryptoFormattedWithNative = "0.005 ETH", - fiatFormatted = "$10.00", - fiatFormattedWithNative = "$10.00", - ) - val quoteModel = buildQuoteModel( - txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee), - ) - - val result = sut.createQuotesLoadedState( - uiStateHolder = baseState, - quoteModel = quoteModel, - feeCryptoCurrencyStatus = null, - swapProvider = buildSwapProvider(), - bestRatedProviderId = "provider-id", - isNeedBestRateBadge = false, - selectedFeeType = FeeType.NORMAL, - needApplyFCARestrictions = false, - hideFee = false, - ) - - val feeContent = result.fee as FeeItemState.Content - assertThat(feeContent.isClickable).isTrue() - assertThat(feeContent.feeType).isEqualTo(FeeType.NORMAL) - assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH") - assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00") - } - - @Test - fun `GIVEN MultipleFeeState WHEN selectedFeeType PRIORITY THEN fee Content has priority fee values and is clickable`() { - val baseState = buildReadyState() - val normalFee = buildLegacyFee( - feeType = FeeType.NORMAL, - cryptoFormatted = "0.001 ETH", - cryptoFormattedWithNative = "0.001 ETH", - fiatFormatted = "$2.00", - fiatFormattedWithNative = "$2.00", - ) - val priorityFee = buildLegacyFee( - feeType = FeeType.PRIORITY, - cryptoFormatted = "0.005 ETH", - cryptoFormattedWithNative = "0.005 ETH", - fiatFormatted = "$10.00", - fiatFormattedWithNative = "$10.00", - ) - val quoteModel = buildQuoteModel( - txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee), - ) - - val result = sut.createQuotesLoadedState( - uiStateHolder = baseState, - quoteModel = quoteModel, - feeCryptoCurrencyStatus = null, - swapProvider = buildSwapProvider(), - bestRatedProviderId = "provider-id", - isNeedBestRateBadge = false, - selectedFeeType = FeeType.PRIORITY, - needApplyFCARestrictions = false, - hideFee = false, - ) - - val feeContent = result.fee as FeeItemState.Content - assertThat(feeContent.isClickable).isTrue() - assertThat(feeContent.feeType).isEqualTo(FeeType.PRIORITY) - assertThat(feeContent.amountCrypto).isEqualTo("0.005 ETH") - assertThat(feeContent.amountFiatFormatted).isEqualTo("$10.00") - } - - @Test - fun `GIVEN hideFee true WHEN any TxFeeState THEN fee is Empty`() { - val baseState = buildReadyState() - val singleFee = buildLegacyFee( - feeType = FeeType.NORMAL, - cryptoFormatted = "0.001 ETH", - cryptoFormattedWithNative = "0.001 ETH", - fiatFormatted = "$2.00", - fiatFormattedWithNative = "$2.00", - ) - val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) - - val result = sut.createQuotesLoadedState( - uiStateHolder = baseState, - quoteModel = quoteModel, - feeCryptoCurrencyStatus = null, - swapProvider = buildSwapProvider(), - bestRatedProviderId = "provider-id", - isNeedBestRateBadge = false, - selectedFeeType = FeeType.NORMAL, - needApplyFCARestrictions = false, - hideFee = true, - ) - - assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) - } - - @Test - fun `GIVEN otherNativeFee greater than feeValue WHEN SingleFeeState THEN amountCrypto reflects the With-Native variant`() { - // Bridge fee scenario: the WithNative formatted strings differ from the plain ones. - // StateBuilder.createFeeState picks `feeCryptoFormattedWithNative` (and fiat) — pinning that. - val baseState = buildReadyState() - val singleFee = buildLegacyFee( - feeType = FeeType.NORMAL, - cryptoFormatted = "0.001 ETH", - cryptoFormattedWithNative = "0.006 ETH", // includes 0.005 bridge native fee - fiatFormatted = "$2.00", - fiatFormattedWithNative = "$12.00", - ) - val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) - - val result = sut.createQuotesLoadedState( - uiStateHolder = baseState, - quoteModel = quoteModel, - feeCryptoCurrencyStatus = null, - swapProvider = buildSwapProvider(), - bestRatedProviderId = "provider-id", - isNeedBestRateBadge = false, - selectedFeeType = FeeType.NORMAL, - needApplyFCARestrictions = false, - hideFee = false, - ) - - val feeContent = result.fee as FeeItemState.Content - assertThat(feeContent.amountCrypto).isEqualTo("0.006 ETH") - assertThat(feeContent.amountFiatFormatted).isEqualTo("$12.00") - } - - @Test - fun `GIVEN otherNativeFee equal to feeValue WHEN SingleFeeState THEN amountCrypto equals plain feeCryptoFormatted`() { - // No bridge fee → WithNative strings happen to equal the plain strings. - val baseState = buildReadyState() - val singleFee = buildLegacyFee( - feeType = FeeType.NORMAL, - cryptoFormatted = "0.0007 ETH", - cryptoFormattedWithNative = "0.0007 ETH", - fiatFormatted = "$1.40", - fiatFormattedWithNative = "$1.40", - ) - val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee)) - - val result = sut.createQuotesLoadedState( - uiStateHolder = baseState, - quoteModel = quoteModel, - feeCryptoCurrencyStatus = null, - swapProvider = buildSwapProvider(), - bestRatedProviderId = "provider-id", - isNeedBestRateBadge = false, - selectedFeeType = FeeType.NORMAL, - needApplyFCARestrictions = false, - hideFee = false, - ) - - val feeContent = result.fee as FeeItemState.Content - assertThat(feeContent.amountCrypto).isEqualTo("0.0007 ETH") - assertThat(feeContent.amountFiatFormatted).isEqualTo("$1.40") - } - - // region — local fixtures - - private fun buildReadyState(): SwapStateHolder { - val fromStatus = buildSwapCurrencyStatus(coldWallet) - val toStatus = buildSwapCurrencyStatus(coldWallet) - return sut.createInitialReadyState( - uiStateHolder = sut.createInitialLoadingState(), - emptyAmountState = SwapState.EmptyAmountState( - zeroAmountEquivalent = stringReference("$0.00"), - ), - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - ) - } - - private fun buildQuoteModel( - txFeeState: TxFeeState, - ): SwapState.QuotesLoadedState { - val fromStatus = buildSwapCurrencyStatus(coldWallet) - val toStatus = buildSwapCurrencyStatus(coldWallet) - val fromInfo = TokenSwapInfo( - tokenAmount = SwapAmount(BigDecimal("1.0"), 18), - amountFiat = BigDecimal("100.00"), - swapCurrencyStatus = fromStatus, - ) - val toInfo = TokenSwapInfo( - tokenAmount = SwapAmount(BigDecimal("0.05"), 18), - amountFiat = BigDecimal("100.00"), - swapCurrencyStatus = toStatus, - ) - return SwapState.QuotesLoadedState( - fromTokenInfo = fromInfo, - toTokenInfo = toInfo, - priceImpact = PriceImpact.Empty, - preparedSwapConfigState = PreparedSwapConfigState( - isBalanceEnough = true, - feeState = SwapFeeState.Enough, - hasOutgoingTransaction = false, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - ), - permissionState = PermissionDataState.Empty, - txFee = txFeeState, - currencyCheck = null, - validationResult = null, - minAdaValue = null, - swapProvider = buildSwapProvider(), - ) - } - - private fun buildSwapProvider(): SwapProvider = SwapProvider( - providerId = "provider-id", - rateTypes = listOf(RateType.FLOAT), - name = "TestProvider", - type = ExchangeProviderType.DEX, - imageLarge = "https://example.com/icon.png", - termsOfUse = null, - privacyPolicy = null, - isRecommended = false, - slippage = null, - isExtraIdSupported = false, - ) - - private fun buildLegacyFee( - feeType: FeeType, - cryptoFormatted: String, - cryptoFormattedWithNative: String, - fiatFormatted: String, - fiatFormattedWithNative: String, - ): TxFee.Legacy { - val fee: Fee = mockk(relaxed = true) - return TxFee.Legacy( - feeValue = BigDecimal("0.001"), - feeFiatFormatted = fiatFormatted, - feeCryptoFormatted = cryptoFormatted, - feeIncludeOtherNativeFee = BigDecimal.ZERO, - feeFiatFormattedWithNative = fiatFormattedWithNative, - feeCryptoFormattedWithNative = cryptoFormattedWithNative, - cryptoSymbol = "ETH", - feeType = feeType, - fee = fee, - ) - } - - // endregion -} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt deleted file mode 100644 index 02fae0c38b..0000000000 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/SwapNotificationsFactoryFeeWarningsTest.kt +++ /dev/null @@ -1,475 +0,0 @@ -package com.tangem.feature.swap - -import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.common.ui.notifications.NotificationUM -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.network.NetworkAddress -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.swap.models.SwapCurrencyStatus -import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType -import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState -import com.tangem.feature.swap.domain.models.domain.RateType -import com.tangem.feature.swap.domain.models.domain.SwapFeeState -import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.ui.FeeType -import com.tangem.feature.swap.domain.models.ui.PermissionDataState -import com.tangem.feature.swap.domain.models.ui.PriceImpact -import com.tangem.feature.swap.domain.models.ui.SwapState -import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo -import com.tangem.feature.swap.domain.models.ui.TxFee -import com.tangem.feature.swap.domain.models.ui.TxFeeState -import com.tangem.feature.swap.model.SwapNotificationsFactory -import com.tangem.feature.swap.models.UiActions -import com.tangem.feature.swap.models.states.SwapNotificationUM -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.Test -import java.math.BigDecimal - -/** - * Characterization tests for fee-related notifications produced by [SwapNotificationsFactory]. - * - * Pinned behavior: - * - [SwapNotificationUM.Error.UnableToCoverFeeWarning]: - * adds when `feeState = NotEnough` AND `isBalanceEnough = true` AND - * `permissionState != PermissionLoading` AND fee currency != fromCurrency, - * AND NOT (gasless network AND CEX provider). - * Suppressed for CEX-on-gasless-network. Re-added unconditionally when - * `includeFeeInAmount is BalanceNotEnough`. - * - [NotificationUM.Warning.FeeCoverageNotification]: triggers on - * `includeFeeInAmount is Included` AND a fee is selected AND no existential deposit. - * - [SwapNotificationUM.Info.PermissionNeeded]: triggers on `permissionState is PermissionRequired`. - * - [SwapNotificationUM.Error.TransactionInProgressWarning]: triggers on - * `hasOutgoingTransaction = true` (when `permissionState is not PermissionLoading`). - * - `hideFee = true` short-circuits `maybeAddUnableCoverFeeWarning` only. - * - * [REDACTED_TASK_KEY] — these guard the redesign that consolidates fee-state into `SwapFee` / - * `FeeBucket`. Phase 5 will rewrite the factory; these tests stay green throughout. - */ -internal class SwapNotificationsFactoryFeeWarningsTest { - - private val actions: UiActions = mockk(relaxed = true) - private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() - - private val sut: SwapNotificationsFactory by lazy { - SwapNotificationsFactory( - actions = actions, - isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, - ) - } - - private val ethNetworkMock: Network = mockk(relaxed = true) { - every { name } returns "Ethereum" - every { currencySymbol } returns "ETH" - every { rawId } returns "ethereum" - } - private val fromCurrency: CryptoCurrency = mockk(relaxed = true) { - every { network } returns ethNetworkMock - every { symbol } returns "ETH" - every { decimals } returns 18 - every { name } returns "Ethereum" - } - private val differentFeeCurrency: CryptoCurrency = mockk(relaxed = true) { - every { network } returns ethNetworkMock - every { symbol } returns "USDC" - every { decimals } returns 6 - every { name } returns "USD Coin" - } - - // ---------- UnableToCoverFeeWarning ---------- - - @Test - fun `UnableToCoverFeeWarning is added when feeState NotEnough and balance enough and not gasless and fee currency differs`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.DEX, - ) - val feeStatus = buildFeeStatus(differentFeeCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue() - } - - @Test - fun `UnableToCoverFeeWarning is suppressed when gasless is available for CEX provider`() { - // Given — CEX + supported network → suppressed - every { isGaslessFeeSupportedForNetwork(any()) } returns true - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.CEX, - ) - val feeStatus = buildFeeStatus(differentFeeCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() - } - - @Test - fun `UnableToCoverFeeWarning is added even when gasless is available IF includeFeeInAmount is BalanceNotEnough`() { - // Given — CEX + supported network BUT BalanceNotEnough overrides the suppression. - every { isGaslessFeeSupportedForNetwork(any()) } returns true - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.BalanceNotEnough, - providerType = ExchangeProviderType.CEX, - ) - val feeStatus = buildFeeStatus(differentFeeCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue() - } - - @Test - fun `UnableToCoverFeeWarning is not added when hideFee true`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"), - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.DEX, - ) - val feeStatus = buildFeeStatus(differentFeeCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = true, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() - } - - @Test - fun `UnableToCoverFeeWarning is not added when feeState is Enough`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.Enough, - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.DEX, - ) - val feeStatus = buildFeeStatus(differentFeeCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse() - } - - // ---------- FeeCoverageNotification ---------- - - @Test - fun `FeeCoverageNotification is added when includeFeeInAmount is Included with a selected fee`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val singleFee = buildLegacyFee(FeeType.NORMAL) - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.Enough, - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Included(SwapAmount(BigDecimal("0.99"), 18)), - providerType = ExchangeProviderType.CEX, - txFeeState = TxFeeState.SingleFeeState(singleFee), - ) - val feeStatus = buildFeeStatus(fromCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isTrue() - } - - @Test - fun `FeeCoverageNotification is not added when includeFeeInAmount is Excluded`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val singleFee = buildLegacyFee(FeeType.NORMAL) - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.Enough, - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.CEX, - txFeeState = TxFeeState.SingleFeeState(singleFee), - ) - val feeStatus = buildFeeStatus(fromCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isFalse() - } - - // ---------- PermissionNeeded ---------- - - @Test - fun `PermissionNeeded is added when permissionState is PermissionRequired`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.Enough, - isBalanceEnough = true, - permissionState = PermissionDataState.PermissionRequired( - isResetApproval = false, - spenderAddress = "0xSpender", - ), - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.DEX, - ) - val feeStatus = buildFeeStatus(fromCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isTrue() - } - - @Test - fun `PermissionNeeded is not added when permissionState is Empty`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.Enough, - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.DEX, - ) - val feeStatus = buildFeeStatus(fromCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isFalse() - } - - // ---------- TransactionInProgressWarning / ApprovalInProgressWarning ---------- - - @Test - fun `ApprovalInProgressWarning is added when permissionState is PermissionLoading`() { - // Given — PermissionLoading short-circuits to ApprovalInProgressWarning (an Error subtype) - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.Enough, - isBalanceEnough = true, - permissionState = PermissionDataState.PermissionLoading, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.DEX, - hasOutgoingTransaction = false, - ) - val feeStatus = buildFeeStatus(fromCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Error.ApprovalInProgressWarning }).isTrue() - } - - @Test - fun `TransactionInProgressWarning is added when hasOutgoingTransaction true and permission not loading`() { - // Given - every { isGaslessFeeSupportedForNetwork(any()) } returns false - val quoteModel = buildQuoteModel( - feeState = SwapFeeState.Enough, - isBalanceEnough = true, - permissionState = PermissionDataState.Empty, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType = ExchangeProviderType.DEX, - hasOutgoingTransaction = true, - ) - val feeStatus = buildFeeStatus(fromCurrency) - - // When - val notifications = sut.getConfirmationStateNotifications( - quoteModel = quoteModel, - feeCryptoCurrencyStatus = feeStatus, - selectedFeeType = FeeType.NORMAL, - hideFee = false, - ) - - // Then - assertThat(notifications.any { it is SwapNotificationUM.Error.TransactionInProgressWarning }).isTrue() - } - - // region — local helpers - - @Suppress("LongParameterList") - private fun buildQuoteModel( - feeState: SwapFeeState = SwapFeeState.Enough, - isBalanceEnough: Boolean = true, - permissionState: PermissionDataState = PermissionDataState.Empty, - includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded, - providerType: ExchangeProviderType = ExchangeProviderType.DEX, - txFeeState: TxFeeState = TxFeeState.Empty, - hasOutgoingTransaction: Boolean = false, - ): SwapState.QuotesLoadedState { - val fromStatus = buildSwapCurrencyStatusForFromCurrency() - val toStatus = buildSwapCurrencyStatusForFromCurrency() - val fromInfo = TokenSwapInfo( - tokenAmount = SwapAmount(BigDecimal("1.0"), 18), - amountFiat = BigDecimal("100.00"), - swapCurrencyStatus = fromStatus, - ) - val toInfo = TokenSwapInfo( - tokenAmount = SwapAmount(BigDecimal("0.05"), 18), - amountFiat = BigDecimal("100.00"), - swapCurrencyStatus = toStatus, - ) - return SwapState.QuotesLoadedState( - fromTokenInfo = fromInfo, - toTokenInfo = toInfo, - priceImpact = PriceImpact.Empty, - preparedSwapConfigState = PreparedSwapConfigState( - isBalanceEnough = isBalanceEnough, - feeState = feeState, - hasOutgoingTransaction = hasOutgoingTransaction, - includeFeeInAmount = includeFeeInAmount, - ), - permissionState = permissionState, - txFee = txFeeState, - currencyCheck = null, - validationResult = null, - minAdaValue = null, - swapProvider = SwapProvider( - providerId = "p", - rateTypes = listOf(RateType.FLOAT), - name = "TestProvider", - type = providerType, - imageLarge = "", - termsOfUse = null, - privacyPolicy = null, - isRecommended = false, - slippage = null, - isExtraIdSupported = false, - ), - ) - } - - private fun buildSwapCurrencyStatusForFromCurrency(): SwapCurrencyStatus { - val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { - every { amount } returns BigDecimal("1.0") - every { fiatRate } returns BigDecimal("2000.00") - every { fiatAmount } returns BigDecimal("2000.00") - every { networkAddress } returns mockk(relaxed = true) - every { pendingTransactions } returns emptySet() - } - val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = fromCurrency, value = statusValue) - val userWallet: UserWallet = mockk(relaxed = true) { - every { walletId } returns UserWalletId("aabbccdd") - } - val account = com.tangem.domain.models.account.Account.CryptoPortfolio.createMainAccount(userWallet.walletId) - return SwapCurrencyStatus( - userWallet = userWallet, - status = cryptoCurrencyStatus, - account = account, - ) - } - - private fun buildFeeStatus(currency: CryptoCurrency): CryptoCurrencyStatus { - val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { - every { amount } returns BigDecimal("0.5") - } - return CryptoCurrencyStatus(currency = currency, value = statusValue) - } - - private fun buildLegacyFee(feeType: FeeType): TxFee.Legacy { - val fee: Fee = mockk(relaxed = true) - return TxFee.Legacy( - feeValue = BigDecimal("0.001"), - feeFiatFormatted = "$2.00", - feeCryptoFormatted = "0.001 ETH", - feeIncludeOtherNativeFee = BigDecimal.ZERO, - feeFiatFormattedWithNative = "$2.00", - feeCryptoFormattedWithNative = "0.001 ETH", - cryptoSymbol = "ETH", - feeType = feeType, - fee = fee, - ) - } - - // endregion -} \ No newline at end of file From edff76b5d9bf45716334defbc38ed4fcd3ee9c32 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 15 May 2026 14:28:36 +0500 Subject: [PATCH 16/23] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 171 ++++++++++++++---- .../tangem/feature/swap/model/SwapModel.kt | 18 +- 2 files changed, 144 insertions(+), 45 deletions(-) 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 9de0be4211..909abd9b3b 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 @@ -668,7 +668,7 @@ internal class SwapInteractorImpl @Inject constructor( ) } else { if (fee == null) return SwapTransactionState.Error.UnknownError - onSwapDexUnified( + onSwapDex( provider = swapProvider, swapData = requireNotNull(swapData), fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -681,12 +681,7 @@ internal class SwapInteractorImpl @Inject constructor( } } - /** - * [REDACTED_TASK_KEY] — Phase 4. DEX swap dispatch using [SwapFee]. Mirrors [onSwapDex] exactly, - * substituting `SwapFee.fee` where the legacy code used `TxFee.fee`. Solana DEX continues - * to use [onSwapSolanaDex] which doesn't consume a fee. - */ - private suspend fun onSwapDexUnified( + private suspend fun onSwapDex( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, @@ -729,15 +724,13 @@ internal class SwapInteractorImpl @Inject constructor( } /** - * [REDACTED_TASK_KEY] — Phase 4. CEX swap dispatch using [SwapFee]. Mirrors [onSwapCex] exactly. - * - * Branch selection (matches legacy): + * Branch selection: * - Gasless token path: `swapFee.transactionFeeResult is LoadedExtended && selectedFeeToken.currency is Token` * → `createAndSendGaslessTransactionUseCase`. * - Otherwise → `sendTransactionUseCase` with `swapFee.fee`. */ @Suppress("LongMethod", "CanBeNonNullable") - private suspend fun onSwapCexUnified( + private suspend fun onSwapCex( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, @@ -833,7 +826,7 @@ internal class SwapInteractorImpl @Inject constructor( createAndSendGaslessTransactionUseCase.invoke( transactionData = txData, userWallet = userWallet, - fee = (fee.transactionFeeResult as TransactionFeeResult.LoadedExtended).fee, + fee = fee.transactionFeeResult.fee, ) } else { sendTransactionUseCase( @@ -1621,21 +1614,77 @@ internal class SwapInteractorImpl @Inject constructor( return getFeePaidCryptoCurrencyStatusSyncUseCase( userWalletId = fromStatus.userWalletId, cryptoCurrencyStatus = fromStatus.status, - ).getOrNull() + ).getOrNull() ?: run { + val feeNetwork = fromStatus.currency.network + + val feePaidCurrency = currenciesRepository.getFeePaidCurrency( + fromStatus.userWalletId, + feeNetwork, + ) + + val (feeCurrency, balance) = when (feePaidCurrency) { + FeePaidCurrency.Coin -> currenciesRepository.createCoinCurrency(feeNetwork) to + walletManagersFacade.getNativeTokenBalance( + userWalletId = fromStatus.userWalletId, + networkId = feeNetwork.rawId, + derivationPath = feeNetwork.derivationPath.value, + ) + is FeePaidCurrency.Token -> currenciesRepository.createTokenCurrency( + userWalletId = fromStatus.userWalletId, + contractAddress = feePaidCurrency.contractAddress, + networkId = feeNetwork.rawId, + ) to feePaidCurrency.balance + is FeePaidCurrency.FeeResource, + FeePaidCurrency.SameCurrency, + -> fromStatus.currency to fromStatus.status.value.amount + } + + val feeCurrencyRawID = feeCurrency.id.rawCurrencyId ?: return@run null + val quote = quotesRepository.getMultiQuoteSyncOrNull(setOf(feeCurrencyRawID)) + ?.firstOrNull()?.value as? QuoteStatus.Data + + CryptoCurrencyStatus( + currency = feeCurrency, + value = if (quote == null) { + CryptoCurrencyStatus.NoQuote( + amount = balance.orZero(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = fromStatus.status.value.networkAddress ?: return@run null, + sources = CryptoCurrencyStatus.Sources(), + ) + } else { + CryptoCurrencyStatus.Loaded( + amount = balance.orZero(), + fiatAmount = quote.fiatRate.multiply(balance), + fiatRate = quote.fiatRate, + priceChange = quote.priceChange, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = fromStatus.status.value.networkAddress ?: return@run null, + sources = CryptoCurrencyStatus.Sources(), + ) + }, + ) + } } /** - * [REDACTED_TASK_KEY] — Phase 4. Patches an existing [SwapState.QuotesLoadedState] with a freshly - * resolved [SwapFee]. See [SwapInteractor.applySwapFee] for the full contract. + * Patches an existing [SwapState.QuotesLoadedState] with a freshly resolved [SwapFee]. + * See [SwapInteractor.applySwapFee] for the full contract. * * Numeric fee used for downstream computation: - * - If `fee.selectedFeeToken.currency` is a token → `0` for the balance/include-fee math when - * the fee currency differs from the from-token (matches legacy `manageWarnings` semantics - * at line 422 of the pre-Phase-4 code). + * - If `fee.selectedFeeToken.currency` is a token → `0` for the balance / include-fee math + * when the fee currency differs from the from-token (matches legacy `manageWarnings` + * semantics at line 422 of the pre-Phase-4 code). * - Otherwise → `fee.fee.amount.value + fee.otherNativeFee` (the bridge-aware native fee). * - * The same `feeToCheck` is fed into `getFeeState`, `isBalanceEnough` and `getIncludeFeeInAmount` - * for consistency with the legacy `loadDexSwapData` path. + * The fee is folded into a single [SwapBalanceStatus] by [computeBalanceStatus], which is + * then assigned to `preparedSwapConfigState.balanceStatus`. */ override suspend fun applySwapFee( state: SwapState.QuotesLoadedState, @@ -1654,29 +1703,19 @@ internal class SwapInteractorImpl @Inject constructor( nativeFee } - val feeState = getFeeState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - fee = nativeFee, - spendAmount = amount, - selectedFeeToken = fee.selectedFeeToken, - ) - val isBalanceIncludeFeeEnough = isBalanceEnough( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - fee = nativeFee, - ) - val includeFeeInAmount = getIncludeFeeInAmount( + val balanceStatus = computeBalanceStatus( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = lastReducedBalanceBy, feeValue = nativeFee, selectedFeeToken = fee.selectedFeeToken, + provider = state.swapProvider, ) val currencyCheck = manageWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, fee = warningsFee, - includeFeeInAmount = includeFeeInAmount, + balanceStatus = balanceStatus, ) val validationResult = manageTransactionValidationWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -1687,9 +1726,7 @@ internal class SwapInteractorImpl @Inject constructor( return state.copy( preparedSwapConfigState = state.preparedSwapConfigState.copy( - isBalanceEnough = isBalanceIncludeFeeEnough, - feeState = feeState, - includeFeeInAmount = includeFeeInAmount, + balanceStatus = balanceStatus, ), currencyCheck = currencyCheck, validationResult = validationResult, @@ -1697,6 +1734,68 @@ internal class SwapInteractorImpl @Inject constructor( ) } + /** + * Decision tree (matches the user-approved derivation table plus the implicit Token-fee sub-case): + * 1. `Included` from `getIncludeFeeInAmountInternal` ⇒ [SwapBalanceStatus.FeeAdjustedAmount]. + * 2. `!isBalanceEnough` (from-token balance can't cover the amount itself) ⇒ + * [SwapBalanceStatus.InsufficientAmount]. + * 3. `feeBalanceState is NotEnough` ⇒ [SwapBalanceStatus.InsufficientFee]. This catches: + * - From-token is a Token, native balance can't cover the fee + * (legacy `includeFeeInAmount=BalanceNotEnough` for the Token branch). + * - From-token is a Coin and `balance - amount < fee` + * (legacy `feeState=NotEnough && includeFeeInAmount=Excluded`). + * 4. Otherwise ⇒ [SwapBalanceStatus.Sufficient]. + * + * The legacy ambiguity where `BalanceNotEnough` meant "amount > balance" for Coin + * from-currencies but "fee > native balance" for Token from-currencies is resolved here + * by consulting `isBalanceEnough` (amount-alone check) directly. + */ + private suspend fun computeBalanceStatus( + fromSwapCurrencyStatus: SwapCurrencyStatus, + amount: SwapAmount, + reduceBalanceBy: BigDecimal, + feeValue: BigDecimal, + selectedFeeToken: CryptoCurrencyStatus?, + provider: SwapProvider, + ): SwapBalanceStatus { + when (provider.type) { + ExchangeProviderType.CEX -> { + val includeStatus = getIncludeFeeInAmountInternal( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + feeValue = feeValue, + selectedFeeToken = selectedFeeToken, + ) + if (includeStatus is IncludeFeeInAmountInternal.Included) { + return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee) + } + } + ExchangeProviderType.DEX, + ExchangeProviderType.DEX_BRIDGE, + -> Unit + } + + val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue) + if (!isAmountAlone) { + return SwapBalanceStatus.InsufficientAmount + } + + val feeBalanceState = getFeeBalanceState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + fee = feeValue, + spendAmount = amount, + selectedFeeToken = selectedFeeToken, + ) + return when (feeBalanceState) { + is FeeBalanceState.Enough -> SwapBalanceStatus.Sufficient + is FeeBalanceState.NotEnough -> SwapBalanceStatus.InsufficientFee( + feeCurrencyName = feeBalanceState.currencyName, + feeCurrencySymbol = feeBalanceState.currencySymbol, + ) + } + } + private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( userWalletId = swapCurrencyStatus.userWalletId, 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 65365603c1..b6ad8a358f 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 @@ -1146,7 +1146,7 @@ internal class SwapModel @Inject constructor( } modelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { - swapInteractor.onSwapWithUnifiedFee( + swapInteractor.onSwap( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, swapProvider = provider, @@ -1999,7 +1999,7 @@ internal class SwapModel @Inject constructor( */ private fun resolveOtherNativeFee(): BigDecimal { val transaction = - dataState.swapDataModel?.transaction as? ExpressTransactionModel.DEX + dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction as? ExpressTransactionModel.DEX ?: return BigDecimal.ZERO val otherNativeFeeWei = transaction.otherNativeFeeWei ?: return BigDecimal.ZERO val nativeDecimals = dataState.fromSwapCurrencyStatus?.currency?.network?.let { network -> @@ -2008,13 +2008,13 @@ internal class SwapModel @Inject constructor( return otherNativeFeeWei.movePointLeft(nativeDecimals) } - private fun com.tangem.features.send.v2.api.entity.FeeItem.toFeeBucket(): FeeBucket = when (this) { - is com.tangem.features.send.v2.api.entity.FeeItem.Slow -> FeeBucket.SLOW - is com.tangem.features.send.v2.api.entity.FeeItem.Market -> FeeBucket.MARKET - is com.tangem.features.send.v2.api.entity.FeeItem.Fast -> FeeBucket.FAST - is com.tangem.features.send.v2.api.entity.FeeItem.Suggested -> FeeBucket.SUGGESTED - is com.tangem.features.send.v2.api.entity.FeeItem.Custom -> FeeBucket.CUSTOM - is com.tangem.features.send.v2.api.entity.FeeItem.Loading -> FeeBucket.MARKET + private fun FeeItem.toFeeBucket(): FeeBucket = when (this) { + is FeeItem.Slow -> FeeBucket.SLOW + is FeeItem.Market -> FeeBucket.MARKET + is FeeItem.Fast -> FeeBucket.FAST + is FeeItem.Suggested -> FeeBucket.SUGGESTED + is FeeItem.Custom -> FeeBucket.CUSTOM + is FeeItem.Loading -> FeeBucket.MARKET } /** From 38f27a326a3a4dab016f4325d638a8fa20cdd686 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 15 May 2026 14:28:57 +0500 Subject: [PATCH 17/23] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt | 4 ---- .../swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt | 3 --- 2 files changed, 7 deletions(-) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt index 9c02543fe9..f608e724fa 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt @@ -13,7 +13,6 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.fee.TransactionFeeResult import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType -import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.ui.* @@ -68,7 +67,6 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() } returns Unit.right() coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() - coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() } @@ -226,11 +224,9 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() preparedSwapConfigState = PreparedSwapConfigState( balanceStatus = SwapBalanceStatus.Pending, hasOutgoingTransaction = false, - includeFeeInAmount = IncludeFeeInAmount.Excluded, ), permissionState = PermissionDataState.Empty, swapDataModel = null, - txFee = TxFeeState.Empty, currencyCheck = null, validationResult = null, minAdaValue = null, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt index 05dc76b5b6..b1af123e7b 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt @@ -12,9 +12,7 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.domain.SwapFeeState import com.tangem.feature.swap.domain.models.ui.SwapState -import com.tangem.feature.swap.domain.models.ui.TxFeeState import io.mockk.coEvery import io.mockk.coVerify import kotlinx.coroutines.test.runTest @@ -76,7 +74,6 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns emptySet() coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() - coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() coEvery { getAllowanceInfoUseCase.invoke(any(), any(), any(), any()) From 596146f65db2ec05614d2d3e058f5a2aafe585a0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 18 May 2026 10:23:19 +0100 Subject: [PATCH 18/23] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 2 - .../tangem/feature/swap/model/SwapModel.kt | 49 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 312a920261..a6a6b012a1 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1382,8 +1382,6 @@ Target account is not created. Please change the amount to send. The amount to send must be at least %s Leave %s - A trustline for %s is required first. - Can\'t receive token Reduce by %s Reduce to %s Kindly be aware that your transaction may experience delays under specific fee settings 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 b6ad8a358f..4d176bfa79 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 @@ -1283,6 +1283,55 @@ internal class SwapModel @Inject constructor( } } + private fun onTransferClick() { + 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") + showAlert() + return + } + uiState = swapTransferStateBuilder.createTransferInProgressState(uiState) + modelScope.launch(dispatchers.main) { + swapTransferInteractor.sendTransfer( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + fee = fee, + transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) { + "It should be not null at this stage" + }, + ).fold( + ifLeft = { error -> + TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") + refreshTransferUIStateAfterFeeUpdate() + 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, + ) + router.replaceAll(SwapRoute.Success) + }, + ) + } + } + private suspend fun processTangemPayWithdrawal( fromSwapCurrencyStatus: SwapCurrencyStatus, swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, From 40ab999383ad9f088675c19a6a73945f0c33fbd5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 00:35:49 +0500 Subject: [PATCH 19/23] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 2 +- .../tangem/feature/swap/model/SwapModel.kt | 11 ++++- .../swap/model/SwapNotificationsFactory.kt | 40 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) 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 909abd9b3b..613f391bf4 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 @@ -1557,7 +1557,7 @@ internal class SwapInteractorImpl @Inject constructor( transaction = transaction, selectedToken = selectedFeeToken, ).fold( - ifLeft = { GetFeeError.UnknownError.left() }, + ifLeft = { error -> GetFeeError.DataError(error).left() }, ifRight = { dexFeeResult -> val feeToken = selectedFeeToken ?: resolveNativeFeeTokenStatus(fromStatus) 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 4d176bfa79..c3aa7806d3 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 @@ -1685,7 +1685,7 @@ internal class SwapModel @Inject constructor( val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch { - feeSelectorRepository.state.value = FeeSelectorUM.Loading + feeSelectorReloadTrigger.triggerLoadingState() feeSelectorReloadTrigger.triggerUpdate() } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) @@ -1710,6 +1710,15 @@ internal class SwapModel @Inject constructor( appRouter.push(route) }, + openTokenDetailsScreen = { cryptoCurrency -> + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions + val route = AppRoute.CurrencyDetails( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = cryptoCurrency, + ) + + appRouter.push(route) + }, onRetryClick = { startLoadingQuotesFromLastState() }, 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 91e28c1b4e..92e79b91b0 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 @@ -379,6 +379,46 @@ internal class SwapNotificationsFactory( } } + private fun MutableList.maybeAddFeeErrorNotification( + feeCryptoCurrencyStatus: CryptoCurrencyStatus?, + quoteModel: SwapState.QuotesLoadedState, + feeError: GetFeeError?, + ) { + if (feeError == null || feeCryptoCurrencyStatus == null) return + when (feeError) { + is GetFeeError.DataError -> { + val error = feeError.cause + if (error is ExpressDataError) { + addAll( + getQuotesErrorStateNotifications( + expressDataError = error, + fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency, + balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus, + swapFee = null, + ), + ) + } else { + addFeeUnreachableNotification( + tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status, + coinStatus = feeCryptoCurrencyStatus, + feeError = feeError, + dustValue = quoteModel.currencyCheck?.dustValue, + onReload = actions.onRetryClick, + onClick = actions.openTokenDetailsScreen, + ) + } + } + else -> addFeeUnreachableNotification( + tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status, + coinStatus = feeCryptoCurrencyStatus, + feeError = feeError, + dustValue = quoteModel.currencyCheck?.dustValue, + onReload = actions.onRetryClick, + onClick = actions.openTokenDetailsScreen, + ) + } + } + private fun MutableList.addReduceAmountNotification( cryptoCurrencyStatus: CryptoCurrencyStatus, fromAmount: SwapAmount, From cf9e84b748ea0f3e392e69b9b4a0bcb616337907 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 15:29:45 +0500 Subject: [PATCH 20/23] Updated on 2026-08-14 --- .../main/java/com/tangem/feature/swap/model/SwapModel.kt | 2 +- .../tangem/feature/swap/model/SwapNotificationsFactory.kt | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 c3aa7806d3..f0a73774ea 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 @@ -1685,7 +1685,7 @@ internal class SwapModel @Inject constructor( val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch { - feeSelectorReloadTrigger.triggerLoadingState() + feeSelectorRepository.state.value = FeeSelectorUM.Loading feeSelectorReloadTrigger.triggerUpdate() } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) 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 92e79b91b0..e504ccfdfb 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 @@ -384,7 +384,13 @@ internal class SwapNotificationsFactory( quoteModel: SwapState.QuotesLoadedState, feeError: GetFeeError?, ) { - if (feeError == null || feeCryptoCurrencyStatus == null) return + if ( + feeError == null || feeCryptoCurrencyStatus == null || + quoteModel.permissionState !is PermissionDataState.Empty + ) { + return + } + when (feeError) { is GetFeeError.DataError -> { val error = feeError.cause From 44cae7ffd85ca497ce7430682d48f687e650bbb9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 14:33:21 +0100 Subject: [PATCH 21/23] Updated on 2026-08-14 --- features/swap/domain/build.gradle.kts | 1 + .../swap/domain/models/ui/SwapState.kt | 5 + .../domain/transfer/SwapTransferInteractor.kt | 6 +- .../transfer/SwapTransferInteractorImpl.kt | 102 ++++- .../SwapTransferInteractorImplTest.kt | 136 +++++-- .../tangem/feature/swap/model/SwapModel.kt | 72 +++- .../swap/model/SwapProcessDataState.kt | 1 + .../SwapTransferNotificationsFactory.kt | 175 +++++++++ .../ui/transfer/SwapTransferStateBuilder.kt | 64 ++- .../SwapTransferNotificationsFactoryTest.kt | 297 ++++++++++++++ .../transfer/SwapTransferStateBuilderTest.kt | 368 ++++++++++++------ 11 files changed, 1048 insertions(+), 179 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 95d56c4314..9712084d11 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(projects.features.swap.api) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) + implementation(projects.features.sendV2.api) implementation(projects.libs.blockchainSdk) /** Other Libraries **/ diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index dd33112779..861066f760 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -40,6 +40,11 @@ sealed interface SwapState { val appCurrency: AppCurrency, val isBalanceHidden: Boolean, val isAccountsMode: Boolean, + val isFeeCoverage: Boolean, + val sendingAmount: BigDecimal, + val currencyCheck: CryptoCurrencyCheck? = null, + val validationResult: Throwable? = null, + val minAdaValue: BigDecimal? = null, ) : SwapState data class EmptyAmountState( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index bd53ada8ec..68ceccef30 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -4,12 +4,14 @@ import arrow.core.Either import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.models.currency.CryptoCurrency +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.error.SendTransactionError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.fee.TransactionFeeResult import com.tangem.feature.swap.domain.models.ui.SwapState +import java.math.BigDecimal interface SwapTransferInteractor { @@ -17,6 +19,8 @@ interface SwapTransferInteractor { fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: String, + feePaidCurrencyStatus: CryptoCurrencyStatus?, + fee: Fee?, ): SwapState fun shouldTransferInsteadOfSwap(fromSwapCurrency: CryptoCurrency?, toSwapCurrency: CryptoCurrency?): Boolean @@ -36,7 +40,7 @@ interface SwapTransferInteractor { suspend fun sendTransfer( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + sendingAmount: BigDecimal, fee: Fee, transactionFeeResult: TransactionFeeResult, ): Either diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 9b26409e31..00ce438d27 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.domain.transfer import arrow.core.Either +import arrow.core.getOrElse import arrow.core.left import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee @@ -17,7 +18,11 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -31,6 +36,8 @@ import com.tangem.feature.swap.domain.fee.TransactionFeeResult import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount +import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkFeeCoverage import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.extensions.orZero import kotlinx.coroutines.flow.first @@ -48,12 +55,16 @@ class SwapTransferInteractorImpl @Inject constructor( private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, + private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, + private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, ) : SwapTransferInteractor { override suspend fun updateTransfer( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: String, + feePaidCurrencyStatus: CryptoCurrencyStatus?, + fee: Fee?, ): SwapState { val fromToken = fromSwapCurrencyStatus.currency val toToken = toSwapCurrencyStatus.currency @@ -63,6 +74,7 @@ class SwapTransferInteractorImpl @Inject constructor( val fromTokenAmountValue = fromTokenAmount.parseBigDecimalOrNull() ?: return createEmptyAmountState(appCurrency) val fromTokenAmountFiat = fromSwapCurrencyStatus.status.value.fiatRate.orZero() * fromTokenAmountValue val fromTokenBalance = fromSwapCurrencyStatus.status.value.amount.orZero() + val userWallet = toSwapCurrencyStatus.userWallet val fromTokenInfo = TokenSwapInfo( tokenAmount = SwapAmount(fromTokenAmountValue, fromToken.decimals), @@ -75,17 +87,86 @@ class SwapTransferInteractorImpl @Inject constructor( swapCurrencyStatus = toSwapCurrencyStatus, amountFiat = fromTokenAmountFiat, ) + // Mirrors legacy manageWarnings in SwapInteractorImpl.applySwapFee: when the fee is paid in + // a token different from the from-token, the fee is deducted from a separate balance, so + // it must not be subtracted from the from-token balance here. + val feePaidCurrency = feePaidCurrencyStatus?.currency + val isFeeInOtherToken = feePaidCurrency is CryptoCurrency.Token && feePaidCurrency.id != fromToken.id + val warningsFee = if (isFeeInOtherToken) BigDecimal.ZERO else fee?.amount?.value.orZero() + val currencyCheck = getCurrencyCheckUseCase( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currencyStatus = fromSwapCurrencyStatus.status, + feeCurrencyStatus = feePaidCurrencyStatus, + amount = fromTokenAmountValue, + fee = warningsFee, + feeCurrencyBalanceAfterTransaction = null, + ) + val (isFeeCoverage, sendingAmount) = getCoverageState( + fromTokenInfo = fromTokenInfo, + userWallet = userWallet, + fee = fee, + currencyCheck = currencyCheck, + ) return SwapState.Transfer( - userWallet = toSwapCurrencyStatus.userWallet, + userWallet = userWallet, fromTokenInfo = fromTokenInfo, toTokenInfo = toTokenInfo, isInsufficientBalance = fromTokenAmountValue > fromTokenBalance, appCurrency = appCurrency, isBalanceHidden = isBalanceHidden, isAccountsMode = isAccountsMode, + isFeeCoverage = isFeeCoverage, + sendingAmount = sendingAmount, + currencyCheck = currencyCheck, ) } + private suspend fun getCoverageState( + fromTokenInfo: TokenSwapInfo, + userWallet: UserWallet, + fee: Fee?, + currencyCheck: CryptoCurrencyCheck, + ): Pair { + val swapCurrencyStatus = fromTokenInfo.swapCurrencyStatus + val isAmountSubtractAvailable = isAmountSubtractAvailable( + userWalletId = userWallet.walletId, + currency = swapCurrencyStatus.currency, + fee = fee, + ) + val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO + val reduceAmountBy = currencyCheck.existentialDeposit.orZero() + val amount = fromTokenInfo.tokenAmount + val feeValue = fee?.amount?.value.orZero() + val isFeeCoverage = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = balance, + amountValue = amount.value, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + val sendingAmount = checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = fromTokenInfo.swapCurrencyStatus.status, + amountValue = amount.value, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + return isFeeCoverage to sendingAmount + } + + private suspend fun isAmountSubtractAvailable( + userWalletId: UserWalletId, + currency: CryptoCurrency, + fee: Fee?, + ): Boolean { + val feeCurrencyId = currency.id + return isAmountSubtractAvailableUseCase( + userWalletId = userWalletId, + currency = currency, + maybeGaslessFee = fee?.let { feeCurrencyId to fee }, + ).getOrElse { false } + } + private fun createEmptyAmountState(appCurrency: AppCurrency): SwapState.EmptyAmountState { return SwapState.EmptyAmountState( zeroAmountEquivalent = stringReference( @@ -168,25 +249,26 @@ class SwapTransferInteractorImpl @Inject constructor( override suspend fun sendTransfer( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + sendingAmount: BigDecimal, fee: Fee, transactionFeeResult: TransactionFeeResult, ): Either { - val amount = fromTokenAmount.parseBigDecimalOrNull()?.takeIf { it.signum() > 0 } - ?: return SendTransactionError.DataError("Can't parse fromTokenAmount: $fromTokenAmount").left() - val destination = toSwapCurrencyStatus.destinationAddress() - ?: return SendTransactionError.DataError("Destination address is null").left() + val destination = toSwapCurrencyStatus.destinationAddress() ?: return getDataError( + message = "Destination address is null", + ) val userWallet = fromSwapCurrencyStatus.userWallet val currency = fromSwapCurrencyStatus.currency val txData = createTransferTransactionUseCase( - amount = amount.convertToSdkAmount(cryptoCurrencyStatus = fromSwapCurrencyStatus.status), + amount = sendingAmount.convertToSdkAmount(cryptoCurrencyStatus = fromSwapCurrencyStatus.status), fee = fee, memo = null, destination = destination, userWalletId = userWallet.walletId, network = currency.network, - ).getOrNull() ?: return SendTransactionError.DataError("Failed to build transfer transaction").left() + ).getOrNull() ?: return getDataError( + message = "Failed to build transfer transaction", + ) return sendTransferForFeeType( userWallet = fromSwapCurrencyStatus.userWallet, @@ -196,6 +278,10 @@ class SwapTransferInteractorImpl @Inject constructor( ) } + private fun getDataError(message: String): Either { + return SendTransactionError.DataError(message).left() + } + private suspend fun sendTransferForFeeType( userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 5252bd405d..c49a417619 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -17,6 +17,9 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase @@ -51,6 +54,8 @@ internal class SwapTransferInteractorImplTest { private val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk() private val sendTransactionUseCase: SendTransactionUseCase = mockk() private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk() + private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() + private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -62,6 +67,8 @@ internal class SwapTransferInteractorImplTest { createTransferTransactionUseCase = createTransferTransactionUseCase, sendTransactionUseCase = sendTransactionUseCase, createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, + getCurrencyCheckUseCase = getCurrencyCheckUseCase, + isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, ) @AfterEach @@ -90,6 +97,8 @@ internal class SwapTransferInteractorImplTest { fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, fromTokenAmount = "abc", + feePaidCurrencyStatus = null, + fee = null, ) assertThat(result).isInstanceOf(SwapState.EmptyAmountState::class.java) @@ -103,12 +112,13 @@ internal class SwapTransferInteractorImplTest { fun `GIVEN valid amount WHEN updateTransfer THEN return Transfer state with mirrored from-and-to swap info`() = runTest { val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") - val userWallet: UserWallet = mockk() + val userWallet: UserWallet = mockk(relaxed = true) val fromCurrencyStatus = buildCurrencyStatus( rawCurrencyId = FROM_RAW_CURRENCY_ID, decimals = FROM_DECIMALS, fiatRate = BigDecimal.TEN, amount = BigDecimal("1.6"), + userWallet = userWallet, ) val toCurrencyStatus = buildCurrencyStatus( rawCurrencyId = TO_RAW_CURRENCY_ID, @@ -118,11 +128,18 @@ internal class SwapTransferInteractorImplTest { every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true + val currencyCheck = buildCurrencyCheck() + coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck + coEvery { + isAmountSubtractAvailableUseCase(any(), any(), any()) + } returns false.right() val result = sut.updateTransfer( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, fromTokenAmount = "1,5", + feePaidCurrencyStatus = null, + fee = null, ) val expectedAmount = BigDecimal("1.5") @@ -143,6 +160,9 @@ internal class SwapTransferInteractorImplTest { appCurrency = appCurrency, isBalanceHidden = true, isAccountsMode = true, + isFeeCoverage = false, + sendingAmount = expectedAmount, + currencyCheck = currencyCheck, ) assertThat(result).isEqualTo(expected) coVerify { isAccountsModeEnabledUseCase.invokeSync() } @@ -152,12 +172,13 @@ internal class SwapTransferInteractorImplTest { @Test fun `GIVEN insufficient amount WHEN updateTransfer THEN return state with insufficient amount`() = runTest { val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") - val userWallet: UserWallet = mockk() + val userWallet: UserWallet = mockk(relaxed = true) val fromCurrencyStatus = buildCurrencyStatus( rawCurrencyId = FROM_RAW_CURRENCY_ID, decimals = FROM_DECIMALS, fiatRate = BigDecimal.TEN, amount = BigDecimal("1.4"), + userWallet = userWallet, ) val toCurrencyStatus = buildCurrencyStatus( rawCurrencyId = TO_RAW_CURRENCY_ID, @@ -167,11 +188,18 @@ internal class SwapTransferInteractorImplTest { every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true + val currencyCheck = buildCurrencyCheck() + coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck + coEvery { + isAmountSubtractAvailableUseCase(any(), any(), any()) + } returns false.right() val result = sut.updateTransfer( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, fromTokenAmount = "1,5", + feePaidCurrencyStatus = null, + fee = null, ) val expectedAmount = BigDecimal("1.5") @@ -192,12 +220,61 @@ internal class SwapTransferInteractorImplTest { appCurrency = appCurrency, isBalanceHidden = true, isAccountsMode = true, + isFeeCoverage = false, + sendingAmount = expectedAmount, + currencyCheck = currencyCheck, ) assertThat(result).isEqualTo(expected) coVerify { isAccountsModeEnabledUseCase.invokeSync() } verify { getBalanceHidingSettingsUseCase.isBalanceHidden() } } + @Test + fun `GIVEN subtract available and fee fills the gap WHEN updateTransfer THEN isFeeCoverage is true and sendingAmount is reduced by fee`() = + runTest { + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk(relaxed = true) + val balance = BigDecimal("1.5") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + amount = balance, + userWallet = userWallet, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + val feeValue = BigDecimal("0.2") + val fee: Fee = mockk(relaxed = true) { + every { amount.value } returns feeValue + } + every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) + every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + coEvery { + getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) + } returns buildCurrencyCheck() + coEvery { + isAmountSubtractAvailableUseCase(any(), any(), any()) + } returns true.right() + + // entered amount = full balance → balance < amount + fee, balance > fee, balance >= amount + // → isFeeCoverage = true, sendingAmount = balance - fee + val result = sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = balance.toPlainString(), + feePaidCurrencyStatus = null, + fee = fee, + ) as SwapState.Transfer + + assertThat(result.isFeeCoverage).isTrue() + assertThat(result.sendingAmount).isEqualTo(balance - feeValue) + } + // endregion // region loadFee @@ -310,31 +387,6 @@ internal class SwapTransferInteractorImplTest { // region sendTransfer - @Test - fun `GIVEN unparsable amount WHEN sendTransfer THEN return DataError`() = runTest { - val fromCurrencyStatus = buildCurrencyStatus( - rawCurrencyId = FROM_RAW_CURRENCY_ID, - decimals = FROM_DECIMALS, - ) - val toCurrencyStatus = buildCurrencyStatus( - rawCurrencyId = TO_RAW_CURRENCY_ID, - decimals = TO_DECIMALS, - destinationAddress = DESTINATION_ADDRESS, - ) - - val result = sut.sendTransfer( - fromSwapCurrencyStatus = fromCurrencyStatus, - toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "abc", - fee = mockk(), - transactionFeeResult = mockk(), - ) - - assertThat(result).isInstanceOf(arrow.core.Either.Left::class.java) - val error = (result as arrow.core.Either.Left).value - assertThat(error).isInstanceOf(SendTransactionError.DataError::class.java) - } - @Test fun `GIVEN missing destination WHEN sendTransfer THEN return DataError`() = runTest { val fromCurrencyStatus = buildCurrencyStatus( @@ -350,7 +402,7 @@ internal class SwapTransferInteractorImplTest { val result = sut.sendTransfer( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "1.0", + sendingAmount = BigDecimal("1.0"), fee = mockk(), transactionFeeResult = mockk(), ) @@ -394,7 +446,7 @@ internal class SwapTransferInteractorImplTest { val result = sut.sendTransfer( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "1.0", + sendingAmount = BigDecimal("1.0"), fee = fee, transactionFeeResult = transactionFeeResult, ) @@ -450,7 +502,7 @@ internal class SwapTransferInteractorImplTest { val result = sut.sendTransfer( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "1.0", + sendingAmount = BigDecimal("1.0"), fee = fee, transactionFeeResult = transactionFeeResult, ) @@ -504,7 +556,7 @@ internal class SwapTransferInteractorImplTest { val result = sut.sendTransfer( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "1.0", + sendingAmount = BigDecimal("1.0"), fee = fee, transactionFeeResult = transactionFeeResult, ) @@ -549,7 +601,7 @@ internal class SwapTransferInteractorImplTest { val result = sut.sendTransfer( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "1.0", + sendingAmount = BigDecimal("1.0"), fee = fee, transactionFeeResult = mockk(), ) @@ -679,12 +731,26 @@ internal class SwapTransferInteractorImplTest { } } + private fun buildCurrencyCheck( + existentialDeposit: BigDecimal? = null, + dustValue: BigDecimal? = null, + reserveAmount: BigDecimal? = null, + ): CryptoCurrencyCheck = CryptoCurrencyCheck( + dustValue = dustValue, + reserveAmount = reserveAmount, + minimumSendAmount = null, + existentialDeposit = existentialDeposit, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = null, + ) + private fun buildCurrencyStatus( rawCurrencyId: CryptoCurrency.RawID?, decimals: Int, fiatRate: BigDecimal = BigDecimal.ZERO, amount: BigDecimal = BigDecimal.ZERO, - userWallet: UserWallet = mockk(), + userWallet: UserWallet = mockk(relaxed = true), destinationAddress: String? = null, symbol: String = "ETH", network: Network = mockk(), @@ -714,6 +780,7 @@ internal class SwapTransferInteractorImplTest { return mockk { every { this@mockk.currency } returns currency every { this@mockk.userWallet } returns userWallet + every { this@mockk.userWalletId } answers { userWallet.walletId } every { this@mockk.status } returns status } } @@ -722,7 +789,7 @@ internal class SwapTransferInteractorImplTest { private fun buildTokenCurrencyStatus( rawCurrencyId: CryptoCurrency.RawID?, decimals: Int, - userWallet: UserWallet = mockk(), + userWallet: UserWallet = mockk(relaxed = true), destinationAddress: String? = null, symbol: String = "USDT", network: Network = mockk(), @@ -769,7 +836,6 @@ internal class SwapTransferInteractorImplTest { const val TX_HASH = "0xabc123" const val FROM_DECIMALS = 18 const val TO_DECIMALS = 6 - val USD_QUOTE: BigDecimal = BigDecimal("2000") val FROM_RAW_CURRENCY_ID = CryptoCurrency.RawID(value = "eth") val TO_RAW_CURRENCY_ID = CryptoCurrency.RawID(value = "matic") } 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 f0a73774ea..f6d78aea97 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 @@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.routing.AppRoute @@ -205,6 +206,7 @@ internal class SwapModel @Inject constructor( ) private val amountDebouncer = Debouncer() + private val transferModeDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() private val performanceTracker = SwapQuotePerformanceTracker() @@ -682,13 +684,18 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: String, + forceUpdate: Boolean = true, ): Boolean { val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency, ) if (shouldTransferInsteadOfSwap) { - modelScope.launch { + transferModeDebouncer.debounce( + coroutineScope = modelScope, + waitMs = DEBOUNCE_AMOUNT_DELAY, + forceUpdate = forceUpdate, + ) { singleTaskScheduler.destroyTask() swapPairsJobHolder.cancel() updateTransferUIState(fromSwapCurrencyStatus, toSwapCurrencyStatus, fromTokenAmount) @@ -702,19 +709,28 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: String, ) { + val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency + val selectedFee = getSelectedSwapFee()?.fee val swapState = swapTransferInteractor.updateTransfer( - fromSwapCurrencyStatus, - toSwapCurrencyStatus, - fromTokenAmount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = fromTokenAmount, + feePaidCurrencyStatus = feePaidCryptoCurrency, + fee = selectedFee, ) when (swapState) { is SwapState.EmptyAmountState -> setupEmptyAmountUiState(swapState, fromSwapCurrencyStatus) is SwapState.Transfer -> { - dataState = dataState.copy(amount = fromTokenAmount) + dataState = dataState.copy( + amount = fromTokenAmount, + currentTransferState = swapState, + ) uiState = swapTransferStateBuilder.createTransferState( actions = actions, transferState = swapState, uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = feePaidCryptoCurrency, + fee = selectedFee, ) feeSelectorRepository.state.value = FeeSelectorUM.Loading feeSelectorReloadTrigger.triggerUpdate() @@ -723,11 +739,36 @@ internal class SwapModel @Inject constructor( } } - private fun refreshTransferUIStateAfterFeeUpdate() { + private fun refreshTransferUIStateAfterFeeUpdateIfNeeded( + feePaidCryptoCurrencyStatus: CryptoCurrencyStatus? = null, + fee: Fee? = null, + ) { val from = dataState.fromSwapCurrencyStatus ?: return val to = dataState.toSwapCurrencyStatus ?: return if (!swapTransferInteractor.shouldTransferInsteadOfSwap(from.currency, to.currency)) return - // todo notification check should be triggered (will be implemented in [REDACTED_TASK_KEY]) + val currentTransferState = dataState.currentTransferState ?: return + val amount = dataState.amount ?: return + modelScope.launch { + // The cached currentTransferState may have been built when the fee selector + // had not loaded yet (fee=null). Recompute it with the freshly-loaded fee so + // isFeeCoverage and sendingAmount reflect the actual fee, otherwise the fee + // coverage notification stays hidden on first Max click. + val refreshed = swapTransferInteractor.updateTransfer( + fromSwapCurrencyStatus = from, + toSwapCurrencyStatus = to, + fromTokenAmount = amount, + feePaidCurrencyStatus = feePaidCryptoCurrencyStatus, + fee = fee, + ) as? SwapState.Transfer ?: currentTransferState + dataState = dataState.copy(currentTransferState = refreshed) + uiState = swapTransferStateBuilder.updateTransferButtonEnableState( + transferState = refreshed, + actions = actions, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, + fee = fee, + ) + } } private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { @@ -1243,12 +1284,13 @@ internal class SwapModel @Inject constructor( showAlert() return } + val transferState = dataState.currentTransferState ?: return uiState = swapTransferStateBuilder.createTransferInProgressState(uiState) modelScope.launch(dispatchers.main) { swapTransferInteractor.sendTransfer( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - fromTokenAmount = lastAmount.value, + sendingAmount = transferState.sendingAmount, fee = fee, transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) { "It should be not null at this stage" @@ -1256,7 +1298,6 @@ internal class SwapModel @Inject constructor( ).fold( ifLeft = { error -> TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") - refreshTransferUIStateAfterFeeUpdate() showAlert() }, ifRight = { txHash -> @@ -1486,6 +1527,7 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = lastAmount.value, + forceUpdate = forceQuotesUpdate, ) if (isUpdatedToTransferMode) return@launch if (toSwapCurrencyStatus.status.value.amount != null) { @@ -2224,21 +2266,22 @@ internal class SwapModel @Inject constructor( override fun onResult(newState: FeeSelectorUM) { state.value = newState - val quoteState = dataState.getCurrentLoadedSwapState() ?: return - if (newState is FeeSelectorUM.Error) { TangemLogger.e("loadFee: ${newState.error}, isHidden = true") + refreshTransferUIStateAfterFeeUpdateIfNeeded() uiState = stateBuilder.createFeeErrorState( uiStateHolder = uiState, - quoteModel = quoteState, + quoteModel = dataState.getCurrentLoadedSwapState() ?: return, feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, feeError = newState.error, ) modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) } - refreshTransferUIStateAfterFeeUpdate() return } - refreshTransferUIStateAfterFeeUpdate() + refreshTransferUIStateAfterFeeUpdateIfNeeded( + feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, + fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee, + ) val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus @@ -2249,6 +2292,7 @@ internal class SwapModel @Inject constructor( ) if (shouldTransferInsteadOfSwap) return + val quoteState = dataState.getCurrentLoadedSwapState() ?: return val swapFee = getSelectedSwapFee() ?: return modelScope.launch(dispatchers.default) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 84f8f76b2d..44368feeef 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -20,6 +20,7 @@ data class SwapProcessDataState( val selectedPairProviders: List = emptyList(), val selectedProvider: SwapProvider? = null, val lastLoadedSwapStates: Map = emptyMap(), + val currentTransferState: SwapState.Transfer? = null, // Amount from input val amount: String? = null, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt new file mode 100644 index 0000000000..7468e1af03 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -0,0 +1,175 @@ +package com.tangem.feature.swap.ui.transfer + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.models.states.SwapNotificationUM +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold +import com.tangem.lib.crypto.BlockchainUtils.isTezos +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal +import javax.inject.Inject + +internal class SwapTransferNotificationsFactory @Inject constructor() { + + fun getNotifications( + transferState: SwapState.Transfer, + feeCryptoCurrencyStatus: CryptoCurrencyStatus?, + fee: Fee?, + onReduceByAmount: (SwapAmount, BigDecimal) -> Unit, + onReduceToAmount: (SwapAmount) -> Unit, + ): ImmutableList { + return buildList { + maybeAddRentExemptionError(transferState) + maybeAddDomainWarnings( + state = transferState, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = fee, + onReduceByAmount = onReduceByAmount, + onReduceToAmount = onReduceToAmount, + ) + maybeAddNeedReserveToCreateAccountWarning(transferState) + }.toPersistentList() + } + + private fun MutableList.maybeAddRentExemptionError(state: SwapState.Transfer) { + state.currencyCheck?.rentWarning?.let { + add(NotificationUM.Solana.RentInfo(it)) + } + } + + private fun MutableList.maybeAddDomainWarnings( + state: SwapState.Transfer, + feeCryptoCurrencyStatus: CryptoCurrencyStatus?, + fee: Fee?, + onReduceByAmount: (SwapAmount, BigDecimal) -> Unit, + onReduceToAmount: (SwapAmount) -> Unit, + ) { + val swapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus + val amount = state.fromTokenInfo.tokenAmount + val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO + val feeValue = fee?.amount?.value.orZero() + val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId) + addExistentialWarningNotification( + existentialDeposit = state.currencyCheck?.existentialDeposit, + feeAmount = feeValue, + sendingAmount = amount.value, + cryptoCurrencyStatus = swapCurrencyStatus.status, + onReduceClick = { reduceBy, reduceByDiff, _ -> + onReduceByAmount( + amount.copy(value = amount.value.minus(reduceByDiff)), + reduceBy, + ) + }, + ) + addValidateTransactionNotifications( + dustValue = state.currencyCheck?.dustValue.orZero(), + validationError = state.validationResult, + cryptoCurrency = swapCurrencyStatus.currency, + minAdaValue = state.minAdaValue, + onReduceClick = { reduceTo, _ -> + onReduceToAmount(amount.copy(value = reduceTo)) + }, + ) + if (!isCardano) { + addDustWarningNotification( + dustValue = state.currencyCheck?.dustValue, + feeValue = feeValue, + sendingAmount = amount.value, + cryptoCurrencyStatus = swapCurrencyStatus.status, + feeCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + addReserveAmountErrorNotification( + reserveAmount = state.currencyCheck?.reserveAmount, + sendingAmount = amount.value, + cryptoCurrency = swapCurrencyStatus.currency, + feeCryptoCurrency = feeCryptoCurrencyStatus?.currency, + isAccountFunded = true, + ) + addReduceAmountNotification( + cryptoCurrencyStatus = swapCurrencyStatus.status, + fromAmount = state.fromTokenInfo.tokenAmount, + balance = balance, + onReduceByAmount = onReduceByAmount, + ) + addTransactionLimitErrorNotification( + currencyCheck = state.currencyCheck, + sendingAmount = amount.value, + cryptoCurrencyStatus = swapCurrencyStatus.status, + feeCurrencyStatus = feeCryptoCurrencyStatus, + feeValue = feeValue, + onReduceClick = { reduceTo, _ -> + onReduceToAmount(amount.copy(value = reduceTo)) + }, + ) + maybeAddFeeCoverageNotification(state = state, amount = amount) + } + + private fun MutableList.maybeAddFeeCoverageNotification( + state: SwapState.Transfer, + amount: SwapAmount, + ) { + addFeeCoverageNotification( + isFeeCoverage = state.isFeeCoverage, + enteredAmountValue = amount.value, + sendingValue = state.sendingAmount, + appCurrency = state.appCurrency, + cryptoCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus.status, + ) + } + + private fun MutableList.maybeAddNeedReserveToCreateAccountWarning(state: SwapState.Transfer) { + val status = state.toTokenInfo.swapCurrencyStatus.status.value + if (status is CryptoCurrencyStatus.NoAccount) { + val amount = state.toTokenInfo.tokenAmount.value + val amountToCreateAccount = status.amountToCreateAccount + val currencyTo = state.toTokenInfo.swapCurrencyStatus.currency + if (amount < amountToCreateAccount) { + add( + SwapNotificationUM.Warning.NeedReserveToCreateAccount( + receiveAmount = status.amountToCreateAccount.parseBigDecimal(currencyTo.decimals), + receiveToken = currencyTo.symbol, + ), + ) + } + } + } + + private fun MutableList.addReduceAmountNotification( + cryptoCurrencyStatus: CryptoCurrencyStatus, + fromAmount: SwapAmount, + balance: BigDecimal, + onReduceByAmount: (SwapAmount, BigDecimal) -> Unit, + ) { + val isTezos = isTezos(cryptoCurrencyStatus.currency.network.rawId) + val threshold = getTezosThreshold() + val isTotalBalance = fromAmount.value >= balance && balance > threshold + if (isTezos && isTotalBalance) { + add( + SwapNotificationUM.Warning.ReduceAmount( + currencyName = cryptoCurrencyStatus.currency.name, + amount = threshold.toPlainString(), + onConfirmClick = { + val patchedAmount = fromAmount.copy( + value = fromAmount.value - threshold, + ) + onReduceByAmount(patchedAmount, threshold) + }, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 5665834d36..451601f4d6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -1,12 +1,13 @@ package com.tangem.feature.swap.ui.transfer -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference @@ -24,13 +25,16 @@ import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo import com.tangem.feature.swap.model.SwapProcessDataState import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.SwapButton.Mode +import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R -import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal import javax.inject.Inject -internal class SwapTransferStateBuilder @Inject constructor() { +internal class SwapTransferStateBuilder @Inject constructor( + private val notificationsFactory: SwapTransferNotificationsFactory, +) { private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -38,13 +42,24 @@ internal class SwapTransferStateBuilder @Inject constructor() { actions: UiActions, transferState: SwapState.Transfer, uiStateHolder: SwapStateHolder, + feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?, + fee: Fee?, ): SwapStateHolder { val fromTokenSwapInfo = transferState.fromTokenInfo val toTokenSwapInfo = transferState.toTokenInfo val isInsufficientBalance = transferState.isInsufficientBalance + val amountTextFieldValue = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.amountTextFieldValue + val notifications = notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, + fee = fee, + onReduceByAmount = actions.onReduceByAmount, + onReduceToAmount = actions.onReduceToAmount, + ) return uiStateHolder.copy( sendCardData = createSendSwapCardState( actions = actions, + amountTextFieldValue = amountTextFieldValue, tokenSwapInfo = fromTokenSwapInfo, appCurrency = transferState.appCurrency, isAccountsMode = transferState.isAccountsMode, @@ -54,6 +69,7 @@ internal class SwapTransferStateBuilder @Inject constructor() { ), receiveCardData = createSendSwapCardState( actions = actions, + amountTextFieldValue = amountTextFieldValue, tokenSwapInfo = toTokenSwapInfo, appCurrency = transferState.appCurrency, isAccountsMode = transferState.isAccountsMode, @@ -69,12 +85,14 @@ internal class SwapTransferStateBuilder @Inject constructor() { onClick = actions.onTransferClick, ), changeCardsButtonState = ChangeCardsButtonState.ENABLED, + notifications = notifications, ) } @Suppress("LongParameterList") private fun createSendSwapCardState( actions: UiActions, + amountTextFieldValue: TextFieldValue?, tokenSwapInfo: TokenSwapInfo, appCurrency: AppCurrency, isAccountsMode: Boolean, @@ -83,7 +101,6 @@ internal class SwapTransferStateBuilder @Inject constructor() { isInsufficientBalance: Boolean, ): SwapCardState { val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus - val formattedSwapAmount = tokenSwapInfo.tokenAmount.formatToUIRepresentation() return SwapCardState.SwapCardData( type = createSendTransactionCardType( @@ -101,10 +118,7 @@ internal class SwapTransferStateBuilder @Inject constructor() { appCurrency = appCurrency, amount = tokenSwapInfo.amountFiat, ), - amountTextFieldValue = TextFieldValue( - text = formattedSwapAmount, - selection = TextRange(index = formattedSwapAmount.length), - ), + amountTextFieldValue = amountTextFieldValue, balance = swapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHidden, ) @@ -190,6 +204,40 @@ internal class SwapTransferStateBuilder @Inject constructor() { } } + fun updateTransferButtonEnableState( + transferState: SwapState.Transfer, + actions: UiActions, + uiStateHolder: SwapStateHolder, + feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?, + fee: Fee?, + ): SwapStateHolder { + val notifications = notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, + fee = fee, + onReduceByAmount = actions.onReduceByAmount, + onReduceToAmount = actions.onReduceToAmount, + ) + return uiStateHolder.copy( + notifications = notifications, + swapButton = uiStateHolder.swapButton.copy( + isEnabled = getTransferButtonEnabled(notifications, fee), + ), + ) + } + + private fun getTransferButtonEnabled(notifications: ImmutableList, fee: Fee?): Boolean { + return fee != null && notifications.none { notification -> + notification is SwapNotificationUM.Error || notification is NotificationUM.Error || + notification is SwapNotificationUM.Warning.ExpressErrorWarning || + notification is SwapNotificationUM.Warning.ExpressGeneralError || + notification is SwapNotificationUM.Warning.NoAvailableTokensToSwap || + notification is SwapNotificationUM.Warning.SwapNotSupported || + notification is SwapNotificationUM.Warning.NeedReserveToCreateAccount || + notification is SwapNotificationUM.Info.PermissionNeeded + } + } + fun createTransferInProgressState(uiState: SwapStateHolder): SwapStateHolder { return uiState.copy( swapButton = uiState.swapButton.copy( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt new file mode 100644 index 0000000000..ab218123aa --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt @@ -0,0 +1,297 @@ +package com.tangem.feature.swap.ui.transfer + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.models.states.SwapNotificationUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapTransferNotificationsFactoryTest { + + private val sut = SwapTransferNotificationsFactory() + + private val userWalletId = UserWalletId(stringValue = "deadbeef") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @Test + fun `GIVEN clean state WHEN getNotifications THEN list is empty`() = runTest { + val transferState = buildTransferState() + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN currencyCheck with rentWarning WHEN getNotifications THEN Solana RentInfo is added`() = runTest { + val rentWarning = CryptoCurrencyWarning.Rent( + rent = BigDecimal("0.01"), + exemptionAmount = BigDecimal("1.0"), + cryptoCurrency = buildCoin(), + ) + val transferState = buildTransferState( + currencyCheck = buildCurrencyCheck(rentWarning = rentWarning), + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN existential deposit greater than diff WHEN getNotifications THEN ExistentialDeposit is added`() = + runTest { + val fromStatus = buildCoinStatus(balance = BigDecimal("1.0")) + val transferState = buildTransferState( + fromTokenInfo = buildTokenInfo( + swapCurrencyStatus = fromStatus, + amount = BigDecimal("0.5"), + ), + currencyCheck = buildCurrencyCheck(existentialDeposit = BigDecimal("0.5")), + ) + val fee: Fee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal("0.4") + } + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = fee, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN dust limit exceeded for coin WHEN getNotifications THEN MinimumAmountError is added`() = runTest { + val fromStatus = buildCoinStatus(balance = BigDecimal("1.0")) + val transferState = buildTransferState( + fromTokenInfo = buildTokenInfo( + swapCurrencyStatus = fromStatus, + amount = BigDecimal("0.0001"), + ), + currencyCheck = buildCurrencyCheck(dustValue = BigDecimal("0.01")), + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN minAdaValue and no validationResult WHEN getNotifications THEN MinAdaValueCharged is added`() = + runTest { + val transferState = buildTransferState( + minAdaValue = BigDecimal("1500000"), + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN transferState with isFeeCoverage true WHEN getNotifications THEN FeeCoverage is added`() = runTest { + val fromStatus = buildCoinStatus(balance = BigDecimal("1.5")) + val transferState = buildTransferState( + fromTokenInfo = buildTokenInfo( + swapCurrencyStatus = fromStatus, + amount = BigDecimal("1.0"), + ), + isFeeCoverage = true, + sendingAmount = BigDecimal("0.5"), + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN toToken has NoAccount status with reserve gap WHEN getNotifications THEN NeedReserveToCreateAccount is added`() = + runTest { + val toStatus = buildNoAccountStatus(amountToCreateAccount = BigDecimal("2.0")) + val transferState = buildTransferState( + toTokenInfo = buildTokenInfo( + swapCurrencyStatus = toStatus, + amount = BigDecimal("0.5"), + ), + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + val reserve = result.filterIsInstance() + assertThat(reserve).hasSize(1) + assertThat(reserve.first().receiveToken).isEqualTo(toStatus.currency.symbol) + } + + @Test + fun `GIVEN Tezos network with total balance amount WHEN getNotifications THEN ReduceAmount is added`() = runTest { + val fromStatus = buildCoinStatus(rawNetworkId = "tezos", balance = BigDecimal("1.0")) + val transferState = buildTransferState( + fromTokenInfo = buildTokenInfo( + swapCurrencyStatus = fromStatus, + amount = BigDecimal("1.0"), + ), + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + + @Suppress("LongParameterList") + private fun buildTransferState( + fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()), + toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()), + currencyCheck: CryptoCurrencyCheck? = null, + validationResult: Throwable? = null, + minAdaValue: BigDecimal? = null, + isFeeCoverage: Boolean = false, + sendingAmount: BigDecimal = fromTokenInfo.tokenAmount.value, + ): SwapState.Transfer = SwapState.Transfer( + userWallet = coldWallet, + fromTokenInfo = fromTokenInfo, + toTokenInfo = toTokenInfo, + isInsufficientBalance = false, + appCurrency = AppCurrency.Default, + isBalanceHidden = false, + isAccountsMode = false, + isFeeCoverage = isFeeCoverage, + sendingAmount = sendingAmount, + currencyCheck = currencyCheck, + validationResult = validationResult, + minAdaValue = minAdaValue, + ) + + private fun buildTokenInfo( + swapCurrencyStatus: SwapCurrencyStatus, + amount: BigDecimal = BigDecimal("0.1"), + ): TokenSwapInfo = TokenSwapInfo( + tokenAmount = SwapAmount(value = amount, decimals = swapCurrencyStatus.currency.decimals), + amountFiat = amount * BigDecimal("2000"), + swapCurrencyStatus = swapCurrencyStatus, + ) + + private fun buildCurrencyCheck( + existentialDeposit: BigDecimal? = null, + dustValue: BigDecimal? = null, + reserveAmount: BigDecimal? = null, + rentWarning: CryptoCurrencyWarning.Rent? = null, + ): CryptoCurrencyCheck = CryptoCurrencyCheck( + dustValue = dustValue, + reserveAmount = reserveAmount, + minimumSendAmount = null, + existentialDeposit = existentialDeposit, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = rentWarning, + ) + + private fun buildCoinStatus( + rawNetworkId: String = "ethereum", + balance: BigDecimal = BigDecimal("1.0"), + fiatRate: BigDecimal = BigDecimal("2000"), + ): SwapCurrencyStatus { + val coin = buildCoin(rawNetworkId = rawNetworkId) + val statusValue: CryptoCurrencyStatus.Loaded = mockk(relaxed = true) { + every { amount } returns balance + every { this@mockk.fiatRate } returns fiatRate + every { fiatAmount } returns balance.multiply(fiatRate) + } + return SwapCurrencyStatus( + userWallet = coldWallet, + status = CryptoCurrencyStatus(currency = coin, value = statusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + } + + private fun buildNoAccountStatus(amountToCreateAccount: BigDecimal): SwapCurrencyStatus { + val coin = buildCoin() + val statusValue: CryptoCurrencyStatus.NoAccount = mockk(relaxed = true) { + every { this@mockk.amountToCreateAccount } returns amountToCreateAccount + } + return SwapCurrencyStatus( + userWallet = coldWallet, + status = CryptoCurrencyStatus(currency = coin, value = statusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + } + + private fun buildCoin(rawNetworkId: String = "ethereum"): CryptoCurrency.Coin { + return mockk(relaxed = true) { + every { id } returns mockk(relaxed = true) + every { network } returns mockk(relaxed = true) { + every { rawId } returns rawNetworkId + every { name } returns "Test Network" + } + every { name } returns "Test Coin" + every { symbol } returns "TST" + every { decimals } returns 18 + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index c9165e2f2a..14bcd8fdea 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -3,19 +3,20 @@ package com.tangem.feature.swap.ui.transfer import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.userwallet.ext.walletInterationIcon +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.swap.models.SwapCurrencyStatus -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.feature.swap.buildSwapCurrencyStatus import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.PriceImpact @@ -25,9 +26,12 @@ import com.tangem.feature.swap.model.SwapProcessDataState import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.presentation.R -import com.tangem.feature.swap.utils.formatToUIRepresentation +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import java.math.BigDecimal @@ -36,7 +40,18 @@ import java.math.BigDecimal internal class SwapTransferStateBuilderTest { private val actions: UiActions = mockk(relaxed = true) - private val sut = SwapTransferStateBuilder() + private val notificationsFactory: SwapTransferNotificationsFactory = mockk(relaxed = true) { + coEvery { + getNotifications( + transferState = any(), + feeCryptoCurrencyStatus = any(), + fee = any(), + onReduceByAmount = any(), + onReduceToAmount = any(), + ) + } returns persistentListOf() + } + private val sut = SwapTransferStateBuilder(notificationsFactory = notificationsFactory) private val userWalletId = UserWalletId(stringValue = "deadbeef") private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { @@ -47,121 +62,193 @@ internal class SwapTransferStateBuilderTest { private val iconConverter = CryptoCurrencyToIconStateConverter() private val fromIcon = iconConverter.convert(fromCurrencyStatus.status) private val toIcon = iconConverter.convert(toCurrencyStatus.status) + private val initialAmountTextFieldValue = TextFieldValue( + text = "0.5", + selection = TextRange(index = 3), + ) @Test - fun `GIVEN accounts mode enabled WHEN createTransferState THEN cards expose Account titles for from and to`() { - val transferState = buildTransferState( - fromAmount = BigDecimal("1.5"), - toAmount = BigDecimal("1.5"), - isAccountsMode = true, - ) + fun `GIVEN accounts mode enabled WHEN createTransferState THEN cards expose Account titles for from and to`() = + runTest { + val transferState = buildTransferState( + fromAmount = BigDecimal("1.5"), + toAmount = BigDecimal("1.5"), + isAccountsMode = true, + ) + val uiState = baseStateHolder() - val result = sut.createTransferState(actions, transferState, baseStateHolder()) + val result = sut.createTransferState( + actions = actions, + transferState = transferState, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = null, + fee = null, + ) - val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio - val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon) - val expectedAccountName = portfolioAccount.accountName.toUM().value - val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable - val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly - assertThat(sendType.accountTitleUM).isEqualTo( - AccountTitleUM.Account( - prefixText = resourceReference(R.string.swapping_from_account_title), - name = expectedAccountName, - icon = expectedAccountIcon, - ), - ) - assertThat(receiveType.accountTitleUM).isEqualTo( - AccountTitleUM.Account( - prefixText = resourceReference(R.string.swapping_to_account_title), - name = expectedAccountName, - icon = expectedAccountIcon, - ), - ) - assertSharedCardShape( - result = result, - transferState = transferState, - ) - } + val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio + val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon) + val expectedAccountName = portfolioAccount.accountName.toUM().value + val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable + val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly + assertThat(sendType.accountTitleUM).isEqualTo( + AccountTitleUM.Account( + prefixText = resourceReference(R.string.swapping_from_account_title), + name = expectedAccountName, + icon = expectedAccountIcon, + ), + ) + assertThat(receiveType.accountTitleUM).isEqualTo( + AccountTitleUM.Account( + prefixText = resourceReference(R.string.swapping_to_account_title), + name = expectedAccountName, + icon = expectedAccountIcon, + ), + ) + assertSharedCardShape( + result = result, + transferState = transferState, + ) + coVerify(exactly = 1) { + notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = any(), + onReduceToAmount = any(), + ) + } + } @Test - fun `GIVEN accounts mode disabled WHEN createTransferState THEN cards fall back to Text titles for from and to`() { - val transferState = buildTransferState( - fromAmount = BigDecimal("2"), - toAmount = BigDecimal("2"), - isAccountsMode = false, - ) + fun `GIVEN accounts mode disabled WHEN createTransferState THEN cards fall back to Text titles for from and to`() = + runTest { + val transferState = buildTransferState( + fromAmount = BigDecimal("2"), + toAmount = BigDecimal("2"), + isAccountsMode = false, + ) + val uiState = baseStateHolder() - val result = sut.createTransferState(actions, transferState, baseStateHolder()) + val result = sut.createTransferState( + actions = actions, + transferState = transferState, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = null, + fee = null, + ) - val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable - val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly - assertThat(sendType.accountTitleUM).isEqualTo( - AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)), - ) - assertThat(receiveType.accountTitleUM).isEqualTo( - AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)), - ) - assertSharedCardShape( - result = result, - transferState = transferState, - ) - } + val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable + val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly + assertThat(sendType.accountTitleUM).isEqualTo( + AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)), + ) + assertThat(receiveType.accountTitleUM).isEqualTo( + AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)), + ) + assertSharedCardShape( + result = result, + transferState = transferState, + ) + coVerify(exactly = 1) { + notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = any(), + onReduceToAmount = any(), + ) + } + } @Test - fun `GIVEN insufficient balance and accounts mode disabled WHEN createTransferState THEN from card shows insufficient funds title and error and swap is disabled`() { - val transferState = buildTransferState( - fromAmount = BigDecimal("10"), - toAmount = BigDecimal("10"), - isAccountsMode = false, - isInsufficientBalance = true, - ) + fun `GIVEN insufficient balance and accounts mode disabled WHEN createTransferState THEN from card shows insufficient funds title and error and swap is disabled`() = + runTest { + val transferState = buildTransferState( + fromAmount = BigDecimal("10"), + toAmount = BigDecimal("10"), + isAccountsMode = false, + isInsufficientBalance = true, + ) + val uiState = baseStateHolder() - val result = sut.createTransferState(actions, transferState, baseStateHolder()) + val result = sut.createTransferState( + actions = actions, + transferState = transferState, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = null, + fee = null, + ) - val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable - val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly - assertThat(sendType.accountTitleUM).isEqualTo( - AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)), - ) - assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds) - assertThat(receiveType.accountTitleUM).isEqualTo( - AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)), - ) - assertThat(result.isInsufficientFunds).isTrue() - assertThat(result.swapButton.isEnabled).isFalse() - assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER) - } + val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable + val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly + assertThat(sendType.accountTitleUM).isEqualTo( + AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)), + ) + assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds) + assertThat(receiveType.accountTitleUM).isEqualTo( + AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)), + ) + assertThat(result.isInsufficientFunds).isTrue() + assertThat(result.swapButton.isEnabled).isFalse() + assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER) + coVerify(exactly = 1) { + notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = any(), + onReduceToAmount = any(), + ) + } + } @Test - fun `GIVEN insufficient balance and accounts mode enabled WHEN createTransferState THEN from card overrides Account title with insufficient funds text`() { - val transferState = buildTransferState( - fromAmount = BigDecimal("10"), - toAmount = BigDecimal("10"), - isAccountsMode = true, - isInsufficientBalance = true, - ) + fun `GIVEN insufficient balance and accounts mode enabled WHEN createTransferState THEN from card overrides Account title with insufficient funds text`() = + runTest { + val transferState = buildTransferState( + fromAmount = BigDecimal("10"), + toAmount = BigDecimal("10"), + isAccountsMode = true, + isInsufficientBalance = true, + ) + val uiState = baseStateHolder() - val result = sut.createTransferState(actions, transferState, baseStateHolder()) + val result = sut.createTransferState( + actions = actions, + transferState = transferState, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = null, + fee = null, + ) - val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio - val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon) - val expectedAccountName = portfolioAccount.accountName.toUM().value - val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable - val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly - assertThat(sendType.accountTitleUM).isEqualTo( - AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)), - ) - assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds) - assertThat(receiveType.accountTitleUM).isEqualTo( - AccountTitleUM.Account( - prefixText = resourceReference(R.string.swapping_to_account_title), - name = expectedAccountName, - icon = expectedAccountIcon, - ), - ) - assertThat(result.isInsufficientFunds).isTrue() - assertThat(result.swapButton.isEnabled).isFalse() - } + val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio + val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon) + val expectedAccountName = portfolioAccount.accountName.toUM().value + val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable + val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly + assertThat(sendType.accountTitleUM).isEqualTo( + AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)), + ) + assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds) + assertThat(receiveType.accountTitleUM).isEqualTo( + AccountTitleUM.Account( + prefixText = resourceReference(R.string.swapping_to_account_title), + name = expectedAccountName, + icon = expectedAccountIcon, + ), + ) + assertThat(result.isInsufficientFunds).isTrue() + assertThat(result.swapButton.isEnabled).isFalse() + coVerify(exactly = 1) { + notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = any(), + onReduceToAmount = any(), + ) + } + } @Test fun `GIVEN content uiState WHEN createTransferInProgressState THEN swap button is disabled in TRANSFER_PROGRESSING mode`() { @@ -181,6 +268,55 @@ internal class SwapTransferStateBuilderTest { assertThat(result.swapButton.onClick).isEqualTo(initialButton.onClick) } + @Test + fun `GIVEN no blocking notifications and non-null fee WHEN updateTransferButtonEnableState THEN swap button becomes enabled`() = + runTest { + val transferState = buildTransferState( + fromAmount = BigDecimal("1"), + toAmount = BigDecimal("1"), + isAccountsMode = false, + ) + val fee: Fee = mockk(relaxed = true) + val uiState = baseStateHolder().copy( + swapButton = SwapButton( + walletInteractionIcon = null, + isEnabled = false, + mode = SwapButton.Mode.TRANSFER, + onClick = {}, + ), + ) + coEvery { + notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = fee, + onReduceByAmount = any(), + onReduceToAmount = any(), + ) + } returns persistentListOf() + + val result = sut.updateTransferButtonEnableState( + transferState = transferState, + actions = actions, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = null, + fee = fee, + ) + + assertThat(result.swapButton.isEnabled).isTrue() + assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER) + assertThat(result.notifications).isEmpty() + coVerify(exactly = 1) { + notificationsFactory.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = fee, + onReduceByAmount = any(), + onReduceToAmount = any(), + ) + } + } + @Test fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() { val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") @@ -242,14 +378,8 @@ internal class SwapTransferStateBuilderTest { ) { val sendCard = result.sendCardData as SwapCardState.SwapCardData val receiveCard = result.receiveCardData as SwapCardState.SwapCardData - val expectedFromText = transferState.fromTokenInfo.tokenAmount.formatToUIRepresentation() - val expectedToText = transferState.toTokenInfo.tokenAmount.formatToUIRepresentation() - assertThat(sendCard.amountTextFieldValue).isEqualTo( - TextFieldValue(text = expectedFromText, selection = TextRange(index = expectedFromText.length)), - ) - assertThat(receiveCard.amountTextFieldValue).isEqualTo( - TextFieldValue(text = expectedToText, selection = TextRange(index = expectedToText.length)), - ) + assertThat(sendCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue) + assertThat(receiveCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue) assertThat(sendCard.currencyIconState).isEqualTo(fromIcon) assertThat(receiveCard.currencyIconState).isEqualTo(toIcon) assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden) @@ -290,14 +420,26 @@ internal class SwapTransferStateBuilderTest { appCurrency = AppCurrency.Default, isBalanceHidden = false, isAccountsMode = isAccountsMode, + isFeeCoverage = false, + sendingAmount = fromAmount, ) } private fun baseStateHolder(): SwapStateHolder = SwapStateHolder( - sendCardData = SwapCardState.Loading( - type = TransactionCardType.ReadOnly( + sendCardData = SwapCardState.SwapCardData( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)), + isEnabled = true, ), + currencyIconState = fromIcon, + tokenSymbol = stringReference(""), + amountEquivalent = TextReference.EMPTY, + amountTextFieldValue = initialAmountTextFieldValue, + balance = "", + isBalanceHidden = false, ), receiveCardData = SwapCardState.Loading( type = TransactionCardType.ReadOnly( From 68a1fede86d0c3d7e6376c335a7b9ea00c18f87d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 21 May 2026 15:25:43 +0100 Subject: [PATCH 22/23] Updated on 2026-08-14 --- .../com/tangem/feature/swap/DefaultSwapComponent.kt | 13 +++++++++---- .../java/com/tangem/feature/swap/model/SwapModel.kt | 2 ++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 52c1cd0ccd..3e10710467 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -153,10 +153,15 @@ internal class DefaultSwapComponent @AssistedInject constructor( val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } val shouldHideBlock by remember { derivedStateOf { - dataState.amount?.parseBigDecimalOrNull().isNullOrZero() || - model.uiState.isInsufficientFunds || - dataState.selectedProvider == null || - dataState.getCurrentLoadedSwapState()?.permissionState !is PermissionDataState.Empty + val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero() + val isInsufficientFunds = model.uiState.isInsufficientFunds + val isProviderMissing = dataState.selectedProvider == null + val loadedState = dataState.getCurrentLoadedSwapState() + val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty + val isInTransferMode = dataState.currentTransferState != null + val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady) + + isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady } } 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 f6d78aea97..53160c708a 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 @@ -597,6 +597,7 @@ internal class SwapModel @Inject constructor( fromTokenAmount = lastAmount.value, ) if (isUpdatedToTransferMode) return + dataState = dataState.copy(currentTransferState = null) modelScope.launch { uiState = stateBuilder.createInitialLoadingState( uiStateHolder = uiState, @@ -1078,6 +1079,7 @@ internal class SwapModel @Inject constructor( emptyAmountState = state, fromSwapCurrencyStatus = fromSwapCurrencyStatus, ) + dataState = dataState.copy(amount = "0") } private fun setupErrorUiState(provider: SwapProvider, state: SwapState.SwapError) { From 0bf534776f5fe05778bf9d5a3e0044d3695932cd Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 22 May 2026 15:31:48 +0100 Subject: [PATCH 23/23] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 577 ------------------ .../swap/domain/di/SwapDomainModule.kt | 12 +- .../SwapInteractorImplFindBestQuoteTest.kt | 1 - .../tangem/feature/swap/model/SwapModel.kt | 86 --- .../swap/model/SwapNotificationsFactory.kt | 46 -- 5 files changed, 2 insertions(+), 720 deletions(-) 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 613f391bf4..b1b03a0bb9 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 @@ -600,288 +600,6 @@ internal class SwapInteractorImpl @Inject constructor( } } - @Suppress("NullableToStringCall") - override suspend fun onSwapWithUnifiedFee( - fromSwapCurrencyStatus: SwapCurrencyStatus, - toSwapCurrencyStatus: SwapCurrencyStatus, - swapProvider: SwapProvider, - swapData: SwapDataModel?, - amountToSwap: String, - includeFeeInAmount: IncludeFeeInAmount, - fee: SwapFee?, - expressOperationType: ExpressOperationType, - isTangemPayWithdrawal: Boolean, - ): SwapTransactionState { - TangemLogger.i( - """ - Swap (unified fee) - |- swapProvider: $swapProvider - |- swapData: $swapData - |- fromSwapCurrencyStatus: - |---- walletId: ${fromSwapCurrencyStatus.userWalletId} - |---- accountId: ${fromSwapCurrencyStatus.account.accountId} - |---- currencyId: ${fromSwapCurrencyStatus.currency.id} - |- toSwapCurrencyStatus: $toSwapCurrencyStatus - |---- walletId: ${toSwapCurrencyStatus.userWalletId} - |---- accountId: ${toSwapCurrencyStatus.account.accountId} - |---- currencyId: ${toSwapCurrencyStatus.currency.id} - |- amountToSwap: $amountToSwap - |- includeFeeInAmount: $includeFeeInAmount - |- fee: $fee - """.trimIndent(), - shouldSanitize = false, - ) - - val userWallet = fromSwapCurrencyStatus.userWallet - if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { - return SwapTransactionState.DemoMode - } - - return when (swapProvider.type) { - ExchangeProviderType.CEX -> { - val amountDecimal = toBigDecimalOrNull(amountToSwap) - val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals) - val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { - includeFeeInAmount.amountSubtractFee - } else { - amount - } - onSwapCexUnified( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - amount = amountToSwapWithFee, - swapFee = fee, - swapProvider = swapProvider, - expressOperationType = expressOperationType, - isTangemPayWithdrawal = isTangemPayWithdrawal, - ) - } - ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - val networkId = fromSwapCurrencyStatus.currency.network.rawId - if (isSolana(networkId)) { - onSwapSolanaDex( - provider = swapProvider, - swapData = requireNotNull(swapData), - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - amountToSwap = amountToSwap, - ) - } else { - if (fee == null) return SwapTransactionState.Error.UnknownError - onSwapDex( - provider = swapProvider, - swapData = requireNotNull(swapData), - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - swapFee = fee, - amountToSwap = amountToSwap, - ) - } - } - } - } - - private suspend fun onSwapDex( - fromSwapCurrencyStatus: SwapCurrencyStatus, - toSwapCurrencyStatus: SwapCurrencyStatus, - provider: SwapProvider, - swapData: SwapDataModel, - amountToSwap: String, - swapFee: SwapFee, - ): SwapTransactionState { - val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } - val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } - val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) - val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX - val dataToSign = dexTransaction.txData - val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) - val txData = createTransactionUseCase( - amount = amountToSend, - fee = swapFee.fee, - memo = null, - destination = swapData.transaction.txTo, - userWalletId = fromSwapCurrencyStatus.userWalletId, - network = toSwapCurrencyStatus.currency.network, - txExtras = createDexTxExtras( - dataToSign, - fromSwapCurrencyStatus.currency.network, - swapFee.fee.getGasLimit(), - ), - ).getOrElse { error -> - TangemLogger.e("Failed to create swap dex tx data", error) - return SwapTransactionState.Error.UnknownError - } - - return handleSwapResult( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - provider = provider, - swapData = swapData, - amount = amount, - txData = txData, - payInAddress = getPayoutAddress(txData), - ) - } - - /** - * Branch selection: - * - Gasless token path: `swapFee.transactionFeeResult is LoadedExtended && selectedFeeToken.currency is Token` - * → `createAndSendGaslessTransactionUseCase`. - * - Otherwise → `sendTransactionUseCase` with `swapFee.fee`. - */ - @Suppress("LongMethod", "CanBeNonNullable") - private suspend fun onSwapCex( - fromSwapCurrencyStatus: SwapCurrencyStatus, - toSwapCurrencyStatus: SwapCurrencyStatus, - amount: SwapAmount, - swapFee: SwapFee?, - swapProvider: SwapProvider, - expressOperationType: ExpressOperationType, - isTangemPayWithdrawal: Boolean, - ): SwapTransactionState { - val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress - val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress - val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() - val exchangeData = repository.getExchangeData( - userWallet = fromSwapCurrencyStatus.userWallet, - fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), - fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, - toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), - fromAddress = fromAddress, - toNetwork = toSwapCurrencyStatus.currency.network.rawId, - fromAmount = amount.toStringWithRightOffset(), - fromDecimals = amount.decimals, - toDecimals = toSwapCurrencyStatus.currency.decimals, - providerId = swapProvider.providerId, - rateType = RateType.FLOAT, - expressOperationType = expressOperationType, - toAddress = toAddress, - refundAddress = fromNetworkAddress?.defaultAddress?.value, - refundExtraId = null, // currently always null, - ).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) } - - val exchangeDataCex = - exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError - - if (isTangemPayWithdrawal) { - return SwapTransactionState.TangemPayWithdrawalData( - cryptoAmount = amount.value, - cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId), - cexAddress = exchangeDataCex.txTo, - fromAmount = amountFormatter.formatSwapAmountToUI( - amount, - fromSwapCurrencyStatus.currency.symbol, - ), - fromAmountValue = amount.value, - toAmount = amountFormatter.formatSwapAmountToUI( - exchangeData.toTokenAmount, - toSwapCurrencyStatus.currency.symbol, - ), - toAmountValue = exchangeData.toTokenAmount.value, - storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - amount = amount, - swapProvider = swapProvider, - swapDataModel = exchangeData, - txExternalUrl = exchangeDataCex.externalTxUrl, - txExternalId = exchangeDataCex.externalTxId, - averageDuration = null, - ), - exchangeData = TangemPayWithdrawExchangeState( - txId = exchangeDataCex.txId, - fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, - fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(), - payInAddress = exchangeData.transaction.txTo, - payInExtraId = exchangeDataCex.txExtraId, - ), - ) - } - - val userWallet = fromSwapCurrencyStatus.userWallet - if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { - return SwapTransactionState.Error.UnknownError - } - val fee = requireNotNull(swapFee) - val txData = createTransferTransactionUseCase( - amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), - fee = fee.fee, - memo = exchangeDataCex.txExtraId, - destination = exchangeDataCex.txTo, - userWalletId = fromSwapCurrencyStatus.userWalletId, - network = fromSwapCurrencyStatus.currency.network, - ).getOrElse { error -> - TangemLogger.e("Failed to create swap CEX tx data", error) - return SwapTransactionState.Error.UnknownError - } - - if (txData.extras == null && exchangeDataCex.txExtraId != null) { - return SwapTransactionState.Error.UnknownError - } - - val isGaslessToken = fee.selectedFeeToken.currency is CryptoCurrency.Token && - fee.transactionFeeResult is TransactionFeeResult.LoadedExtended - val result = if (isGaslessToken) { - createAndSendGaslessTransactionUseCase.invoke( - transactionData = txData, - userWallet = userWallet, - fee = fee.transactionFeeResult.fee, - ) - } else { - sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = fromSwapCurrencyStatus.currency.network, - ) - } - - val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress - val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() - return result.fold( - ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) }, - ifRight = { txHash -> - repository.exchangeSent( - userWallet = userWallet, - txId = exchangeDataCex.txId, - fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, - fromAddress = cexFromAddress, - payInAddress = getPayoutAddress(txData), - txHash = txHash, - payInExtraId = exchangeDataCex.txExtraId, - ) - val timestamp = System.currentTimeMillis() - val txExternalUrl = exchangeDataCex.externalTxUrl - storeSwapTransaction( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - amount = amount, - swapProvider = swapProvider, - swapDataModel = exchangeData, - timestamp = timestamp, - txExternalUrl = txExternalUrl, - txExternalId = exchangeDataCex.externalTxId, - ) - storeLastCryptoCurrencyId(toSwapCurrencyStatus) - SwapTransactionState.TxSent( - fromAmount = amountFormatter.formatSwapAmountToUI( - amount, - fromSwapCurrencyStatus.currency.symbol, - ), - fromAmountValue = amount.value, - toAmount = amountFormatter.formatSwapAmountToUI( - exchangeData.toTokenAmount, - toSwapCurrencyStatus.currency.symbol, - ), - toAmountValue = exchangeData.toTokenAmount.value, - txHash = txHash, - txExternalUrl = txExternalUrl, - timestamp = timestamp, - ) - }, - ) - } - private suspend fun onSwapDex( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -1501,301 +1219,6 @@ internal class SwapInteractorImpl @Inject constructor( } } - /** - * [REDACTED_TASK_KEY] — Phase 3 unified fee API. Delegates to [DexSwapFeeCalculator] / - * [CexSwapFeeCalculator] and wraps the result in a [SwapFee]. - * - * Behavior parity with the legacy `loadFeeForSwapTransaction` overloads is intentional — - * the legacy methods stay in place through Phase 4. See `SwapInteractor.loadSwapFee` for - * the full contract. - */ - @Suppress("LongParameterList", "ReturnCount") - override suspend fun loadSwapFee( - provider: SwapProvider, - fromStatus: SwapCurrencyStatus, - toStatus: SwapCurrencyStatus, - amount: SwapAmount, - swapData: SwapDataModel?, - selectedFeeToken: CryptoCurrencyStatus?, - ): Either = either { - if (amount.value.signum() == 0) { - raise(GetFeeError.UnknownError) - } - return when (provider.type) { - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> loadDexSwapFee( - fromStatus = fromStatus, - swapData = swapData, - selectedFeeToken = selectedFeeToken, - ) - ExchangeProviderType.CEX -> loadCexSwapFee( - fromStatus = fromStatus, - amount = amount, - selectedFeeToken = selectedFeeToken, - ) - } - } - - /** - * [REDACTED_TASK_KEY] — DEX branch of [loadSwapFee]. Pulls the cached `ExpressTransactionModel.DEX` - * out of [swapData] and hands it to [DexSwapFeeCalculator]. Maps [ExpressDataError] → - * `Left(GetFeeError.UnknownError)` to keep the unified surface a single error type, matching - * what the legacy `loadFeeForSwapTransaction` overload 2 does for DEX failures (line 1027 of - * the original code). - */ - private suspend fun loadDexSwapFee( - fromStatus: SwapCurrencyStatus, - swapData: SwapDataModel?, - selectedFeeToken: CryptoCurrencyStatus?, - ): Either { - val transaction = swapData?.transaction as? ExpressTransactionModel.DEX - ?: return GetFeeError.UnknownError.left() - - return dexSwapFeeCalculator.calculate( - fromSwapCurrencyStatus = fromStatus, - transaction = transaction, - selectedToken = selectedFeeToken, - ).fold( - ifLeft = { error -> GetFeeError.DataError(error).left() }, - ifRight = { dexFeeResult -> - val feeToken = selectedFeeToken - ?: resolveNativeFeeTokenStatus(fromStatus) - ?: return@fold GetFeeError.UnknownError.left() - SwapFeeFactory.from( - transactionFeeResult = dexFeeResult.transactionFee, - selectedFeeToken = feeToken, - otherNativeFee = dexFeeResult.otherNativeFee, - feeBucket = FeeBucket.MARKET, - ).right() - }, - ) - } - - /** - * [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when - * [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator]) - * decides native vs token. The resulting `SwapFee.selectedFeeToken` is the explicit choice - * if provided, otherwise the native coin status of the from-token's network. - */ - private suspend fun loadCexSwapFee( - fromStatus: SwapCurrencyStatus, - amount: SwapAmount, - selectedFeeToken: CryptoCurrencyStatus?, - ): Either { - return cexSwapFeeCalculator.calculate( - userWallet = fromStatus.userWallet, - fromSwapCurrencyStatus = fromStatus, - amount = amount.value, - selectedFeeToken = selectedFeeToken, - ).fold( - ifLeft = { it.left() }, - ifRight = { cexFeeResult -> - val feeToken = selectedFeeToken - ?: resolveNativeFeeTokenStatus(fromStatus) - ?: return@fold GetFeeError.UnknownError.left() - SwapFeeFactory.from( - transactionFeeResult = cexFeeResult.transactionFee, - selectedFeeToken = feeToken, - otherNativeFee = BigDecimal.ZERO, - feeBucket = FeeBucket.MARKET, - ).right() - }, - ) - } - - /** - * [REDACTED_TASK_KEY] — resolves the native-coin [CryptoCurrencyStatus] for the from-token's network. - * Used as the default `selectedFeeToken` of [SwapFee] when the caller did not provide an - * explicit choice. Mirrors how `SwapModel.updateFeePaidCryptoCurrencyFor` populates - * `dataState.feePaidCryptoCurrency`. - */ - private suspend fun resolveNativeFeeTokenStatus(fromStatus: SwapCurrencyStatus): CryptoCurrencyStatus? { - return getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = fromStatus.userWalletId, - cryptoCurrencyStatus = fromStatus.status, - ).getOrNull() ?: run { - val feeNetwork = fromStatus.currency.network - - val feePaidCurrency = currenciesRepository.getFeePaidCurrency( - fromStatus.userWalletId, - feeNetwork, - ) - - val (feeCurrency, balance) = when (feePaidCurrency) { - FeePaidCurrency.Coin -> currenciesRepository.createCoinCurrency(feeNetwork) to - walletManagersFacade.getNativeTokenBalance( - userWalletId = fromStatus.userWalletId, - networkId = feeNetwork.rawId, - derivationPath = feeNetwork.derivationPath.value, - ) - is FeePaidCurrency.Token -> currenciesRepository.createTokenCurrency( - userWalletId = fromStatus.userWalletId, - contractAddress = feePaidCurrency.contractAddress, - networkId = feeNetwork.rawId, - ) to feePaidCurrency.balance - is FeePaidCurrency.FeeResource, - FeePaidCurrency.SameCurrency, - -> fromStatus.currency to fromStatus.status.value.amount - } - - val feeCurrencyRawID = feeCurrency.id.rawCurrencyId ?: return@run null - val quote = quotesRepository.getMultiQuoteSyncOrNull(setOf(feeCurrencyRawID)) - ?.firstOrNull()?.value as? QuoteStatus.Data - - CryptoCurrencyStatus( - currency = feeCurrency, - value = if (quote == null) { - CryptoCurrencyStatus.NoQuote( - amount = balance.orZero(), - stakingBalance = null, - yieldSupplyStatus = null, - hasCurrentNetworkTransactions = false, - pendingTransactions = emptySet(), - networkAddress = fromStatus.status.value.networkAddress ?: return@run null, - sources = CryptoCurrencyStatus.Sources(), - ) - } else { - CryptoCurrencyStatus.Loaded( - amount = balance.orZero(), - fiatAmount = quote.fiatRate.multiply(balance), - fiatRate = quote.fiatRate, - priceChange = quote.priceChange, - stakingBalance = null, - yieldSupplyStatus = null, - hasCurrentNetworkTransactions = false, - pendingTransactions = emptySet(), - networkAddress = fromStatus.status.value.networkAddress ?: return@run null, - sources = CryptoCurrencyStatus.Sources(), - ) - }, - ) - } - } - - /** - * Patches an existing [SwapState.QuotesLoadedState] with a freshly resolved [SwapFee]. - * See [SwapInteractor.applySwapFee] for the full contract. - * - * Numeric fee used for downstream computation: - * - If `fee.selectedFeeToken.currency` is a token → `0` for the balance / include-fee math - * when the fee currency differs from the from-token (matches legacy `manageWarnings` - * semantics at line 422 of the pre-Phase-4 code). - * - Otherwise → `fee.fee.amount.value + fee.otherNativeFee` (the bridge-aware native fee). - * - * The fee is folded into a single [SwapBalanceStatus] by [computeBalanceStatus], which is - * then assigned to `preparedSwapConfigState.balanceStatus`. - */ - override suspend fun applySwapFee( - state: SwapState.QuotesLoadedState, - fee: SwapFee, - lastReducedBalanceBy: BigDecimal, - ): SwapState.QuotesLoadedState { - val fromSwapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus - val amount = state.fromTokenInfo.tokenAmount - val isFeeInToken = fee.selectedFeeToken.currency is CryptoCurrency.Token - val nativeFee = (fee.fee.amount.value ?: BigDecimal.ZERO) + fee.otherNativeFee - - // Mirrors legacy manageWarnings: token-fee paths skip the native deduction. - val warningsFee = if (isFeeInToken && fromSwapCurrencyStatus.currency.id != fee.selectedFeeToken.currency.id) { - BigDecimal.ZERO - } else { - nativeFee - } - - val balanceStatus = computeBalanceStatus( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = lastReducedBalanceBy, - feeValue = nativeFee, - selectedFeeToken = fee.selectedFeeToken, - provider = state.swapProvider, - ) - val currencyCheck = manageWarnings( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - fee = warningsFee, - balanceStatus = balanceStatus, - ) - val validationResult = manageTransactionValidationWarnings( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - feeValue = nativeFee, - ) - val minAdaValue = (fee.fee as? Fee.CardanoToken)?.minAdaValue - - return state.copy( - preparedSwapConfigState = state.preparedSwapConfigState.copy( - balanceStatus = balanceStatus, - ), - currencyCheck = currencyCheck, - validationResult = validationResult, - minAdaValue = minAdaValue, - ) - } - - /** - * Decision tree (matches the user-approved derivation table plus the implicit Token-fee sub-case): - * 1. `Included` from `getIncludeFeeInAmountInternal` ⇒ [SwapBalanceStatus.FeeAdjustedAmount]. - * 2. `!isBalanceEnough` (from-token balance can't cover the amount itself) ⇒ - * [SwapBalanceStatus.InsufficientAmount]. - * 3. `feeBalanceState is NotEnough` ⇒ [SwapBalanceStatus.InsufficientFee]. This catches: - * - From-token is a Token, native balance can't cover the fee - * (legacy `includeFeeInAmount=BalanceNotEnough` for the Token branch). - * - From-token is a Coin and `balance - amount < fee` - * (legacy `feeState=NotEnough && includeFeeInAmount=Excluded`). - * 4. Otherwise ⇒ [SwapBalanceStatus.Sufficient]. - * - * The legacy ambiguity where `BalanceNotEnough` meant "amount > balance" for Coin - * from-currencies but "fee > native balance" for Token from-currencies is resolved here - * by consulting `isBalanceEnough` (amount-alone check) directly. - */ - private suspend fun computeBalanceStatus( - fromSwapCurrencyStatus: SwapCurrencyStatus, - amount: SwapAmount, - reduceBalanceBy: BigDecimal, - feeValue: BigDecimal, - selectedFeeToken: CryptoCurrencyStatus?, - provider: SwapProvider, - ): SwapBalanceStatus { - when (provider.type) { - ExchangeProviderType.CEX -> { - val includeStatus = getIncludeFeeInAmountInternal( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - feeValue = feeValue, - selectedFeeToken = selectedFeeToken, - ) - if (includeStatus is IncludeFeeInAmountInternal.Included) { - return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee) - } - } - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> Unit - } - - val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue) - if (!isAmountAlone) { - return SwapBalanceStatus.InsufficientAmount - } - - val feeBalanceState = getFeeBalanceState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - fee = feeValue, - spendAmount = amount, - selectedFeeToken = selectedFeeToken, - ) - return when (feeBalanceState) { - is FeeBalanceState.Enough -> SwapBalanceStatus.Sufficient - is FeeBalanceState.NotEnough -> SwapBalanceStatus.InsufficientFee( - feeCurrencyName = feeBalanceState.currencyName, - feeCurrencySymbol = feeBalanceState.currencySymbol, - ) - } - } - private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( userWalletId = swapCurrencyStatus.userWalletId, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 4d915395ad..d4c16b5b71 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -1,18 +1,10 @@ package com.tangem.feature.swap.domain.di +import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase -import com.tangem.core.abtests.manager.ABTestsManager -import com.tangem.feature.swap.domain.AllowPermissionsHandler -import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl -import com.tangem.feature.swap.domain.GetSwapUiModeUseCase -import com.tangem.feature.swap.domain.SetSwapUiModeUseCase -import com.tangem.feature.swap.domain.SwapFeedbackUseCase -import com.tangem.feature.swap.domain.SwapInteractor -import com.tangem.feature.swap.domain.SwapInteractorImpl -import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase @@ -23,9 +15,9 @@ import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor import com.tangem.feature.swap.domain.transfer.SwapTransferInteractorImpl +import com.tangem.features.swap.SwapFeatureToggles import dagger.Binds import dagger.Module import dagger.Provides diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index bf926cf0da..0c0243150f 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -238,7 +238,6 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( providers = listOf(dexProvider), amountToSwap = "1.0", reduceBalanceBy = BigDecimal.ZERO, - ) // Then — has a result entry for the DEX provider; type of state is decided by internal logic 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 53160c708a..ce2ca9769e 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 @@ -1326,55 +1326,6 @@ internal class SwapModel @Inject constructor( } } - private fun onTransferClick() { - 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") - showAlert() - return - } - uiState = swapTransferStateBuilder.createTransferInProgressState(uiState) - modelScope.launch(dispatchers.main) { - swapTransferInteractor.sendTransfer( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - fromTokenAmount = lastAmount.value, - fee = fee, - transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) { - "It should be not null at this stage" - }, - ).fold( - ifLeft = { error -> - TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") - refreshTransferUIStateAfterFeeUpdate() - 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, - ) - router.replaceAll(SwapRoute.Success) - }, - ) - } - } - private suspend fun processTangemPayWithdrawal( fromSwapCurrencyStatus: SwapCurrencyStatus, swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, @@ -1754,15 +1705,6 @@ internal class SwapModel @Inject constructor( appRouter.push(route) }, - openTokenDetailsScreen = { cryptoCurrency -> - val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions - val route = AppRoute.CurrencyDetails( - userWalletId = fromSwapCurrencyStatus.userWalletId, - currency = cryptoCurrency, - ) - - appRouter.push(route) - }, onRetryClick = { startLoadingQuotesFromLastState() }, @@ -2119,34 +2061,6 @@ internal class SwapModel @Inject constructor( is FeeItem.Loading -> FeeBucket.MARKET } - /** - * [REDACTED_TASK_KEY] — Phase 4. Extracts the bridge protocol fee from the cached - * [SwapDataModel.transaction] payload (DEX bridge providers carry `otherNativeFeeWei`). - * - * The UI's `FeeSelectorUM` doesn't carry this value, so we read it from the most-recent - * swap data. Returns [BigDecimal.ZERO] when no swap data is cached, the transaction is not - * a DEX payload, or `otherNativeFeeWei` is null (non-bridge providers). - */ - private fun resolveOtherNativeFee(): BigDecimal { - val transaction = - dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction as? ExpressTransactionModel.DEX - ?: return BigDecimal.ZERO - val otherNativeFeeWei = transaction.otherNativeFeeWei ?: return BigDecimal.ZERO - val nativeDecimals = dataState.fromSwapCurrencyStatus?.currency?.network?.let { network -> - Blockchain.fromNetworkId(network.rawId)?.decimals() - } ?: return BigDecimal.ZERO - return otherNativeFeeWei.movePointLeft(nativeDecimals) - } - - private fun FeeItem.toFeeBucket(): FeeBucket = when (this) { - is FeeItem.Slow -> FeeBucket.SLOW - is FeeItem.Market -> FeeBucket.MARKET - is FeeItem.Fast -> FeeBucket.FAST - is FeeItem.Suggested -> FeeBucket.SUGGESTED - is FeeItem.Custom -> FeeBucket.CUSTOM - is FeeItem.Loading -> FeeBucket.MARKET - } - inner class FeeSelectorRepository : SwapFeeSelectorBlockComponent.ModelRepositoryExtended { override val state = MutableStateFlow( 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 e504ccfdfb..91e28c1b4e 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 @@ -379,52 +379,6 @@ internal class SwapNotificationsFactory( } } - private fun MutableList.maybeAddFeeErrorNotification( - feeCryptoCurrencyStatus: CryptoCurrencyStatus?, - quoteModel: SwapState.QuotesLoadedState, - feeError: GetFeeError?, - ) { - if ( - feeError == null || feeCryptoCurrencyStatus == null || - quoteModel.permissionState !is PermissionDataState.Empty - ) { - return - } - - when (feeError) { - is GetFeeError.DataError -> { - val error = feeError.cause - if (error is ExpressDataError) { - addAll( - getQuotesErrorStateNotifications( - expressDataError = error, - fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency, - balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus, - swapFee = null, - ), - ) - } else { - addFeeUnreachableNotification( - tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status, - coinStatus = feeCryptoCurrencyStatus, - feeError = feeError, - dustValue = quoteModel.currencyCheck?.dustValue, - onReload = actions.onRetryClick, - onClick = actions.openTokenDetailsScreen, - ) - } - } - else -> addFeeUnreachableNotification( - tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status, - coinStatus = feeCryptoCurrencyStatus, - feeError = feeError, - dustValue = quoteModel.currencyCheck?.dustValue, - onReload = actions.onRetryClick, - onClick = actions.openTokenDetailsScreen, - ) - } - } - private fun MutableList.addReduceAmountNotification( cryptoCurrencyStatus: CryptoCurrencyStatus, fromAmount: SwapAmount,