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 0989af8561..d38c5ee14d 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 @@ -1549,9 +1549,9 @@ internal class SwapInteractorImpl @Inject constructor( * * 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). + * when the fee currency differs from the from-token. + * - Otherwise → `fee.fee.amount.value` (already the bridge-aware native total: folds + * `otherNativeFee` into `fee.amount` in `SwapFeeFactory`, so it must NOT be re-added here). * * The fee is folded into a single [SwapBalanceStatus] by [computeBalanceStatus], which is * then assigned to `preparedSwapConfigState.balanceStatus`. @@ -1564,7 +1564,7 @@ internal class SwapInteractorImpl @Inject constructor( 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 + val nativeFee = fee.fee.amount.value ?: BigDecimal.ZERO // Mirrors legacy manageWarnings: token-fee paths skip the native deduction. val warningsFee = if (isFeeInToken && fromSwapCurrencyStatus.currency.id != fee.selectedFeeToken.currency.id) { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/SwapFeeFactory.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/SwapFeeFactory.kt index cc59fd8930..8c531156b8 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/SwapFeeFactory.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/SwapFeeFactory.kt @@ -2,6 +2,7 @@ package com.tangem.feature.swap.domain.fee 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.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.models.ui.FeeBucket @@ -11,7 +12,7 @@ import java.math.BigDecimal /** * Builds [SwapFee] instances from raw [TransactionFeeResult] payloads. * - * [REDACTED_TASK_KEY] — Phase 3. Keeps the bucket-selection rules in one place so that + * Keeps the bucket-selection rules in one place so that * `SwapInteractor.loadSwapFee` (DEX path, CEX path) and `applySwapFee` (added in Phase 4) stay * in sync. * @@ -20,6 +21,22 @@ import java.math.BigDecimal * Market). When the caller explicitly asks for a tier other than the default `MARKET`, the * matching [Fee] is sourced from the [TransactionFee] payload; otherwise `MARKET` is the * default since every variant exposes a `normal` field. + * + * ## Bridge-fee folding + * + * For DEX_BRIDGE providers the express quote carries a native-coin bridge protocol fee + * (`otherNativeFee`) that the wallet pays on top of the network gas fee — both leave the wallet + * in the **native coin**. To make the single "Network fee" shown to the user reflect the true + * native cost, this factory folds `otherNativeFee` into every tier of the produced + * [Fee] / [TransactionFee] — but only when the fee is paid in the native coin. After folding: + * - [SwapFee.fee].amount.value = gas + bridge — the single source of truth used for display, + * balance/validation, the success screen and the fee-coverage warning; + * - [SwapFee.otherNativeFee] = the bridge portion **already included** in `fee.amount`, retained + * only so a gas-only figure can be recovered (`fee.amount - otherNativeFee`, e.g. the + * "high network fee" check). It is never added again downstream. + * + * The fold is skipped for token-denominated (gasless) fees, because a native-coin addend cannot + * be summed into a token fee — see [fromLoadedExtended]. */ object SwapFeeFactory { @@ -31,22 +48,28 @@ object SwapFeeFactory { * @param selectedFeeToken the currency that pays the fee. For native fee paths this is the * native coin status of the from-token's network. * @param otherNativeFee bridge protocol fee from `DexFeeResult.otherNativeFee`. Zero - * unless the provider is DEX_BRIDGE. + * unless the provider is DEX_BRIDGE. When positive and the fee is native, it is folded into + * `fee`/`transactionFeeResult` (see class docs) so the displayed fee is the gas+bridge total. * @param feeBucket the tier to use; defaults to [FeeBucket.MARKET]. The selected - * [SwapFee.fee] is sourced from the [TransactionFee] shape accordingly. + * [SwapFee.fee] is sourced from the (folded) [TransactionFee] shape accordingly. */ fun fromLoaded( transactionFeeResult: TransactionFeeResult.Loaded, selectedFeeToken: CryptoCurrencyStatus, otherNativeFee: BigDecimal = BigDecimal.ZERO, feeBucket: FeeBucket = FeeBucket.MARKET, - ): SwapFee = SwapFee( - fee = selectFee(transactionFeeResult.fee, feeBucket), - transactionFeeResult = transactionFeeResult, - selectedFeeToken = selectedFeeToken, - otherNativeFee = otherNativeFee, - feeBucket = feeBucket, - ) + ): SwapFee { + val foldedFee = transactionFeeResult.fee.foldNativeFee(selectedFeeToken, otherNativeFee) + val foldedResult = + if (foldedFee === transactionFeeResult.fee) transactionFeeResult else TransactionFeeResult.Loaded(foldedFee) + return SwapFee( + fee = selectFee(foldedResult.fee, feeBucket), + transactionFeeResult = foldedResult, + selectedFeeToken = selectedFeeToken, + otherNativeFee = otherNativeFee, + feeBucket = feeBucket, + ) + } /** * Builds a [SwapFee] from a [TransactionFeeResult.LoadedExtended] (gasless / token-fee @@ -54,6 +77,16 @@ object SwapFeeFactory { * * `LoadedExtended` always carries a single [TransactionFeeExtended.transactionFee] (no * slow/normal/priority choice), so the bucket defaults to [FeeBucket.MARKET]. + * + * The native-coin `otherNativeFee` is **not** folded here: a gasless fee is + * token-denominated, and a native addend cannot be summed into a token fee (different + * currency). DEX gasless is currently unreachable (`SwapModel.loadFeeExtended` returns a + * `GaslessError` for DEX/DEX_BRIDGE), so this branch never carries a non-zero `otherNativeFee` + * today — the guard below is defensive. + * + * TODO [REDACTED_TASK_KEY]: when DEX gasless support lands, surface/charge the native `otherNativeFee` + * separately here instead of folding it into the token gas fee. Tie-in with the + * "TODO support gasless in DEX/DEX_BRIDGE" note in `SwapModel.loadFeeExtended`. */ fun fromLoadedExtended( transactionFeeResult: TransactionFeeResult.LoadedExtended, @@ -117,4 +150,65 @@ object SwapFeeFactory { } is TransactionFee.Single -> transactionFee.normal } + + // region [REDACTED_TASK_KEY] — bridge-fee folding + + /** + * Folds a native-coin [otherNativeFee] into every tier of this [TransactionFee], but only + * when the fee is paid in the native coin (`selectedFeeToken` is a [CryptoCurrency.Coin]) and + * the amount is positive. Returns `this` unchanged otherwise — identity is preserved so + * non-bridge swaps produce a byte-identical result. + */ + private fun TransactionFee.foldNativeFee( + selectedFeeToken: CryptoCurrencyStatus, + otherNativeFee: BigDecimal, + ): TransactionFee { + val canFold = otherNativeFee.signum() > 0 && selectedFeeToken.currency is CryptoCurrency.Coin + return if (canFold) plusNativeFee(otherNativeFee) else this + } + + /** Adds [delta] to the `amount` of every tier of this [TransactionFee]. */ + private fun TransactionFee.plusNativeFee(delta: BigDecimal): TransactionFee = when (this) { + is TransactionFee.Choosable -> copy( + minimum = minimum.plusNativeAmount(delta), + normal = normal.plusNativeAmount(delta), + priority = priority.plusNativeAmount(delta), + ) + is TransactionFee.Single -> copy(normal = normal.plusNativeAmount(delta)) + } + + /** + * Returns a copy of this [Fee] with [delta] added to `amount.value`, preserving every other + * field (gasLimit/gasPrice etc. stay intact, so signing is unaffected). No-op when [delta] is + * zero (identity preserved). Token-denominated fees ([Fee.Ethereum.TokenCurrency], + * [Fee.CardanoToken]) are returned unchanged — a native-coin addend must never be summed into + * a token fee. The `when` is exhaustive so a new SDK [Fee] subtype fails compilation rather + * than silently skipping the fold. High cyclomatic complexity is inherent to that exhaustive + * per-subtype `copy` dispatch (no branching logic), so the rule is suppressed here. + */ + @Suppress("CyclomaticComplexMethod") + private fun Fee.plusNativeAmount(delta: BigDecimal): Fee { + if (delta.signum() == 0) return this + val newAmount = amount.copy(value = (amount.value ?: BigDecimal.ZERO) + delta) + return when (this) { + // Token-denominated fees — a native-coin addend must never be summed in. + is Fee.Ethereum.TokenCurrency, + is Fee.CardanoToken, + -> this + is Fee.Ethereum.Legacy -> copy(amount = newAmount) + is Fee.Ethereum.EIP1559 -> copy(amount = newAmount) + is Fee.Alephium -> copy(amount = newAmount) + is Fee.Aptos -> copy(amount = newAmount) + is Fee.Bitcoin -> copy(amount = newAmount) + is Fee.Common -> copy(amount = newAmount) + is Fee.Filecoin -> copy(amount = newAmount) + is Fee.Hedera -> copy(amount = newAmount) + is Fee.Kaspa -> copy(amount = newAmount) + is Fee.Sui -> copy(amount = newAmount) + is Fee.Tron -> copy(amount = newAmount) + is Fee.VeChain -> copy(amount = newAmount) + } + } + + // endregion } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapFee.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapFee.kt index 8e158aa5d6..05f4014eed 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapFee.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapFee.kt @@ -13,10 +13,15 @@ import java.math.BigDecimal * full [TransactionFeeResult] (so gasless / token-paid sends can use the same payload), the * selected fee token, the optional bridge protocol fee, and the fee tier classifier. * - * @property fee the concrete [Fee] that will be signed and broadcast on-chain. For - * `TransactionFee.Single`-shaped responses this is the only choice; for - * `TransactionFee.Choosable`-shaped responses it is the bucket selected by the user (or the - * default MARKET tier when no selection has been made). + * @property fee the concrete [Fee] for the selected tier. For `TransactionFee.Single`-shaped + * responses this is the only choice; for `TransactionFee.Choosable`-shaped responses it is the + * bucket selected by the user (or the default MARKET tier when no selection has been made). + * + * For DEX_BRIDGE providers `fee.amount.value` is the **total native network cost = + * gas + bridge protocol fee** — the single source of truth used for display, balance/validation, + * the success screen and the fee-coverage warning (the bridge fee is folded in by + * `SwapFeeFactory`). The gas portion used for signing is unaffected (broadcast uses the on-chain + * `txValue` + gasLimit/gasPrice, not `fee.amount`). * @property transactionFeeResult the full transaction-fee payload returned by the underlying * use case. Preserved verbatim so it can be passed through to gasless send flows * (`CreateAndSendGaslessTransactionUseCase` requires the [TransactionFeeResult.LoadedExtended] @@ -28,6 +33,11 @@ import java.math.BigDecimal * @property otherNativeFee bridge protocol fee (e.g. carried by `ExpressTransactionModel.DEX * .otherNativeFeeWei` for DEX_BRIDGE providers). Always [BigDecimal.ZERO] unless the provider * is `DEX_BRIDGE`. Propagated from [com.tangem.feature.swap.domain.fee.DexFeeResult]. + * + * This is the bridge portion **already included** in [fee].amount (folded by + * `SwapFeeFactory` for native-coin fees). It is **not** additive — do not add it on top of + * `fee.amount` anywhere. It is retained only so the gas-only figure can be recovered + * (`fee.amount.value - otherNativeFee`), e.g. for the "high network fee" check. * @property feeBucket tier classifier derived from the parent [TransactionFee] shape (see * [FeeBucket] mapping table). Drives analytics through [FeeBucket.toAnalyticsName]. */ 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 87d2938747..84b4db538a 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 @@ -149,6 +149,27 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java) } + @Test + fun `applySwapFee — folded fee — otherNativeFee is not double counted`() = runTest { + // Discriminating case: folded fee.amount = 0.002, otherNativeFee = 0.001, balance = 0.0025. + // Using fee.amount alone (0.002 <= 0.0025) → Sufficient. If otherNativeFee were re-added + // (0.002 + 0.001 = 0.003 > 0.0025) it would flip to InsufficientFee. Asserting Sufficient + // proves the bridge fee is counted exactly once. + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.0025") + + val state = buildQuotesLoadedState( + fromAmount = SwapAmount(BigDecimal("1"), 18), + isCoin = false, + fromBalance = BigDecimal("10"), + ) + val swapFee = buildSwapFee(feeValue = BigDecimal("0.001"), otherNativeFee = BigDecimal("0.001")) + + val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy) + + assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java) + } + @Test fun `applySwapFee — FeeResource branch flips to Sufficient on isFeeResourceEnough`() = runTest { coEvery { @@ -235,8 +256,11 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() } private fun buildSwapFee(feeValue: BigDecimal, otherNativeFee: BigDecimal = BigDecimal.ZERO): SwapFee { + // [REDACTED_TASK_KEY]: production folds otherNativeFee into fee.amount (SwapFeeFactory), so the fee + // handed to applySwapFee is already the gas+bridge total. Simulate that folded input here; + // otherNativeFee is still carried as the included portion but is NOT re-added by applySwapFee. val amount = mockk(relaxed = true) { - every { value } returns feeValue + every { value } returns feeValue + otherNativeFee } val fee = mockk(relaxed = true) { every { this@mockk.amount } returns amount diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/SwapFeeFactoryTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/SwapFeeFactoryTest.kt index d03319e3e2..f46ddc40ec 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/SwapFeeFactoryTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/SwapFeeFactoryTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Amount 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.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.models.ui.FeeBucket @@ -220,6 +221,126 @@ internal class SwapFeeFactoryTest { assertThat(result.otherNativeFee).isEqualTo(BigDecimal.ZERO) } + // ------------------------------------------------------------------------- + // [REDACTED_TASK_KEY] — bridge-fee folding + // ------------------------------------------------------------------------- + + @Test + fun `GIVEN native coin fee WHEN fromLoaded with Single THEN otherNativeFee folded into fee amount`() { + // Arrange + val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002"))) + val bridgeFee = BigDecimal("0.5") + + // Act + val result = SwapFeeFactory.fromLoaded( + transactionFeeResult = TransactionFeeResult.Loaded(singleFee), + selectedFeeToken = coinFeeTokenStatus(), + otherNativeFee = bridgeFee, + ) + + // Assert — selected fee and the underlying tier both carry gas + bridge + assertThat(result.fee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.502")) + val folded = (result.transactionFeeResult as TransactionFeeResult.Loaded).fee as TransactionFee.Single + assertThat(folded.normal.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.502")) + // otherNativeFee retained as the included portion (non-additive downstream) + assertThat(result.otherNativeFee).isEquivalentAccordingToCompareTo(bridgeFee) + } + + @Test + fun `GIVEN native coin fee WHEN fromLoaded with Choosable THEN all tiers folded`() { + // Arrange + val choosable = TransactionFee.Choosable( + minimum = ethLegacyFee(BigDecimal("0.001")), + normal = ethLegacyFee(BigDecimal("0.002")), + priority = ethLegacyFee(BigDecimal("0.003")), + ) + + // Act + val result = SwapFeeFactory.fromLoaded( + transactionFeeResult = TransactionFeeResult.Loaded(choosable), + selectedFeeToken = coinFeeTokenStatus(), + otherNativeFee = BigDecimal("0.5"), + feeBucket = FeeBucket.MARKET, + ) + + // Assert + val folded = (result.transactionFeeResult as TransactionFeeResult.Loaded).fee as TransactionFee.Choosable + assertThat(folded.minimum.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.501")) + assertThat(folded.normal.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.502")) + assertThat(folded.priority.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.503")) + // Selected MARKET fee = folded normal + assertThat(result.fee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.502")) + } + + @Test + fun `GIVEN token fee token WHEN fromLoaded with nonzero otherNativeFee THEN not folded`() { + // Arrange — fee paid in a token: a native bridge fee must not be summed into it + val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002"))) + + // Act + val result = SwapFeeFactory.fromLoaded( + transactionFeeResult = TransactionFeeResult.Loaded(singleFee), + selectedFeeToken = tokenFeeTokenStatus(), + otherNativeFee = BigDecimal("0.5"), + ) + + // Assert — fee unchanged, otherNativeFee retained separately + assertThat(result.fee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.002")) + assertThat(result.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("0.5")) + } + + @Test + fun `GIVEN TokenCurrency fee subtype WHEN fold attempted THEN returned unchanged`() { + // Arrange — native coin fee token but a token-denominated Fee subtype: must stay unchanged + val singleFee = TransactionFee.Single(normal = ethTokenCurrencyFee(BigDecimal("0.002"))) + + // Act + val result = SwapFeeFactory.fromLoaded( + transactionFeeResult = TransactionFeeResult.Loaded(singleFee), + selectedFeeToken = coinFeeTokenStatus(), + otherNativeFee = BigDecimal("0.5"), + ) + + // Assert + assertThat(result.fee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.002")) + } + + @Test + fun `GIVEN zero otherNativeFee WHEN fromLoaded THEN result identity preserved`() { + // Arrange + val loaded = TransactionFeeResult.Loaded(TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))) + + // Act + val result = SwapFeeFactory.fromLoaded( + transactionFeeResult = loaded, + selectedFeeToken = coinFeeTokenStatus(), + otherNativeFee = BigDecimal.ZERO, + ) + + // Assert — no rebuild when nothing to fold + assertThat(result.transactionFeeResult).isSameInstanceAs(loaded) + } + + @Test + fun `GIVEN gasless extended fee WHEN fromLoadedExtended with nonzero otherNativeFee THEN not folded`() { + // Arrange + val rawFee = ethLegacyFee(BigDecimal("0.002")) + val extended = mockk(relaxed = true) { + io.mockk.every { transactionFee } returns TransactionFee.Single(normal = rawFee) + } + + // Act + val result = SwapFeeFactory.fromLoadedExtended( + transactionFeeResult = TransactionFeeResult.LoadedExtended(extended), + selectedFeeToken = coinFeeTokenStatus(), + otherNativeFee = BigDecimal("0.5"), + ) + + // Assert — gasless (token-denominated) fee is never folded; otherNativeFee kept separate + assertThat(result.fee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.002")) + assertThat(result.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("0.5")) + } + // ------------------------------------------------------------------------- // from() generic dispatcher // ------------------------------------------------------------------------- @@ -281,4 +402,22 @@ internal class SwapFeeFactoryTest { gasLimit = BigInteger.valueOf(100_000), gasPrice = BigInteger.valueOf(20_000_000_000), ) + + private fun ethTokenCurrencyFee(value: BigDecimal): Fee.Ethereum.TokenCurrency = Fee.Ethereum.TokenCurrency( + amount = Amount(currencySymbol = "USDT", value = value, decimals = 6), + gasLimit = BigInteger.valueOf(100_000), + coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.valueOf(21_000), + baseGas = BigInteger.ZERO, + ) + + /** A fee-token status whose currency really is a [CryptoCurrency.Coin] (so folding applies). */ + private fun coinFeeTokenStatus(): CryptoCurrencyStatus = mockk(relaxed = true) { + io.mockk.every { currency } returns mockk(relaxed = true) + } + + /** A fee-token status whose currency is a [CryptoCurrency.Token] (so folding is skipped). */ + private fun tokenFeeTokenStatus(): CryptoCurrencyStatus = mockk(relaxed = true) { + io.mockk.every { currency } returns mockk(relaxed = true) + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 71fd844cf0..83e17b24fd 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 @@ -1194,8 +1194,17 @@ internal class SwapModel @Inject constructor( private suspend fun isHighNetworkFee(swapFee: SwapFee?): Boolean { if (!swapFeatureToggles.isHighFeeWarningEnabled) return false swapFee ?: return false - val feeAmount = swapFee.fee.amount.value ?: return false - return isHighNetworkFeeUseCase(swapFee.selectedFeeToken.currency, feeAmount) + val totalFeeAmount = swapFee.fee.amount.value ?: return false + // SwapFeeFactory folds otherNativeFee into fee.amount ONLY for native-coin fees. Recover the + // gas-only value by subtracting it back in exactly that case; for token-denominated (gasless) + // fees the bridge fee is not folded. Mirrors the guard + // in SwapFeeFactory.foldNativeFee; relevant once DEX gasless supports non-zero native amounts. + val gasFeeAmount = if (swapFee.selectedFeeToken.currency is CryptoCurrency.Coin) { + totalFeeAmount - swapFee.otherNativeFee + } else { + totalFeeAmount + } + return isHighNetworkFeeUseCase(swapFee.selectedFeeToken.currency, gasFeeAmount) } private fun sendAnalyticsForNotifications( 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 1fffe4938d..b4533913bd 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 @@ -299,7 +299,8 @@ internal class SwapNotificationsFactory( private fun formatFeeCoverageNotification(swapFee: SwapFee): NotificationUM.Warning.FeeCoverageNotification { val feeAmount = swapFee.fee.amount - val totalFeeValue = (feeAmount.value ?: BigDecimal.ZERO) + swapFee.otherNativeFee + // fee.amount already includes the bridge fee (folded in SwapFeeFactory); no re-add. + val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO val cryptoAmount = totalFeeValue.format { crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index b4a62370f8..650ccdf0ee 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -1324,7 +1324,8 @@ internal class StateBuilder( private fun formatSwapFeeForSuccess(swapFee: SwapFee): TextReference { val feeAmount = swapFee.fee.amount - val totalFeeValue = (feeAmount.value ?: BigDecimal.ZERO) + swapFee.otherNativeFee + // fee.amount already includes the bridge fee (folded in SwapFeeFactory); no re-add. + val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO val cryptoFormatted = totalFeeValue.format { crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals) }