Updated on 2026-08-14
This commit is contained in:
parent
d282e2648f
commit
64ea4f5df7
8 changed files with 301 additions and 23 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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].
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<Amount>(relaxed = true) {
|
||||
every { value } returns feeValue
|
||||
every { value } returns feeValue + otherNativeFee
|
||||
}
|
||||
val fee = mockk<Fee.Common>(relaxed = true) {
|
||||
every { this@mockk.amount } returns amount
|
||||
|
|
|
|||
|
|
@ -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<TransactionFeeExtended>(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<CryptoCurrency.Coin>(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<CryptoCurrency.Token>(relaxed = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue