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