Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 13:04:37 +02:00
parent d6a43b3b62
commit c8ded3b7d5
2 changed files with 179 additions and 9 deletions

View file

@ -2,11 +2,13 @@ package com.tangem.feature.swap.domain.fee
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.blockchain.common.transaction.Fee
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.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
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
@ -22,16 +24,15 @@ import java.math.BigDecimal
*
* Strategy is selected by [selectedFeeToken]:
* - `null` gasless. Calls [EstimateFeeForGaslessTxUseCase] which itself decides whether to use
* a native or token fee. **No gas-limit bump is applied** here, matching production behavior of
* overload 1.
* a native or token fee. When it resolves to a **native** Ethereum fee, the 5% gas-limit bump is
* applied for parity with the explicit native path; token-paid fees carry their own safety
* margins and are **not bumped**.
* - non-null + token currency calls [EstimateFeeForTokenUseCase]. **No gas-limit bump.**
* - non-null + native (coin) currency calls [EstimateFeeUseCase]. **The 5% gas-limit bump is
* applied via [patchEthGasLimitForSwap]** for parity with `loadFeeForSwapTransaction` overload 2.
* The bump is a no-op for non-Ethereum fees, so this is safe across chains.
*
* Behavior is byte-for-byte identical to the original methods in `SwapInteractorImpl`. The
* original code is intentionally retained alongside this calculator until the caller is migrated
* to delegate to it (the migration is deferred see plan).
* This is the single CEX fee path: `SwapInteractorImpl.loadSwapFee` delegates here for CEX swaps.
*/
class CexSwapFeeCalculator(
private val estimateFeeUseCase: EstimateFeeUseCase,
@ -54,13 +55,14 @@ class CexSwapFeeCalculator(
val transactionFeeResult: TransactionFeeResult = if (isGasless) {
when {
selectedFeeToken == null -> {
// Gasless path — overload 1 in SwapInteractorImpl. No gas-limit bump.
// Gasless path — overload 1 in SwapInteractorImpl. Bumped only when it
// resolves to a native Ethereum fee (see patchIfNativeEthereumFee).
val feeExtended = estimateFeeForGaslessTxUseCase(
amount = amount,
userWallet = userWallet,
sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status,
).bind()
TransactionFeeResult.LoadedExtended(feeExtended)
TransactionFeeResult.LoadedExtended(feeExtended.patchIfNativeEthereumFee())
}
selectedFeeToken.currency is CryptoCurrency.Token -> {
// Explicit gasless-token path — overload 1 in SwapInteractorImpl. No gas-limit bump.
@ -94,4 +96,20 @@ class CexSwapFeeCalculator(
CexFeeResult(transactionFee = transactionFeeResult)
}
/**
* Applies the gas-limit bump when the gasless use case resolved to a native-paid Ethereum fee
* ([Fee.Ethereum.EIP1559] / [Fee.Ethereum.Legacy]) such a fee is signed and sent as a regular
* transaction, exactly like the explicit native path, so it needs the same headroom. Token-paid
* ([Fee.Ethereum.TokenCurrency]) and non-Ethereum fees are returned unchanged: the former carries
* its own safety margins, the latter would be a no-op for the patch anyway.
*/
private fun TransactionFeeExtended.patchIfNativeEthereumFee(): TransactionFeeExtended {
return when (transactionFee.normal) {
is Fee.Ethereum.EIP1559,
is Fee.Ethereum.Legacy,
-> copy(transactionFee = patchEthGasLimitForSwap(transactionFee))
else -> this
}
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.blockchainsdk.utils.toNetworkId
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.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
@ -94,7 +95,7 @@ internal class CexSwapFeeCalculatorTest {
@Test
fun `GIVEN null selectedFeeToken WHEN calculate THEN delegates to estimateFeeForGaslessTxUseCase`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val expected = mockk<TransactionFeeExtended>(relaxed = true)
val expected = tokenPaidExtendedFee(fromStatus)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns expected.right()
@ -131,6 +132,142 @@ internal class CexSwapFeeCalculatorTest {
}
}
@Test
fun `GIVEN gasless resolves to native Legacy fee WHEN calculate THEN 5 percent bump applied`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val rawExtended = TransactionFeeExtended(
transactionFee = TransactionFee.Single(
normal = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
),
),
feeTokenId = fromStatus.status.currency.id,
)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns rawExtended.right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.5"),
selectedFeeToken = null,
isGasless = true,
)
assertThat(result.isRight()).isTrue()
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
val patched = (loaded.fee.transactionFee as TransactionFee.Single).normal as Fee.Ethereum.Legacy
// 100_000 * 105 / 100 = 105_000
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(105_000))
// 105_000 * 20_000_000_000 / 1e18 = 0.0000021
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0000021"))
// Extended-fee metadata is preserved.
assertThat(loaded.fee.feeTokenId).isEqualTo(rawExtended.feeTokenId)
assertThat(loaded.fee.gaslessFeePlan).isNull()
assertThat(loaded.fee.mainTransactionGasLimit).isNull()
assertThat(loaded.fee.withdrawGasLimit).isNull()
}
}
@Test
fun `GIVEN gasless resolves to native Choosable EIP1559 fee WHEN calculate THEN bump applied to all three legs`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val maxFeePerGas = BigInteger.valueOf(10_000_000_000)
val priorityFee = BigInteger.valueOf(1_000_000_000)
fun eip1559(gasLimit: Long, amount: String) = Fee.Ethereum.EIP1559(
amount = Amount(currencySymbol = "ETH", value = BigDecimal(amount), decimals = 18),
gasLimit = BigInteger.valueOf(gasLimit),
maxFeePerGas = maxFeePerGas,
priorityFee = priorityFee,
)
val rawExtended = TransactionFeeExtended(
transactionFee = TransactionFee.Choosable(
minimum = eip1559(gasLimit = 50_000, amount = "0.0000005"),
normal = eip1559(gasLimit = 100_000, amount = "0.000001"),
priority = eip1559(gasLimit = 150_000, amount = "0.0000015"),
),
feeTokenId = fromStatus.status.currency.id,
)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns rawExtended.right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = null,
isGasless = true,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
val patched = loaded.fee.transactionFee as TransactionFee.Choosable
assertThat((patched.minimum as Fee.Ethereum.EIP1559).gasLimit)
.isEqualTo(BigInteger.valueOf(52_500))
assertThat((patched.normal as Fee.Ethereum.EIP1559).gasLimit)
.isEqualTo(BigInteger.valueOf(105_000))
assertThat((patched.priority as Fee.Ethereum.EIP1559).gasLimit)
.isEqualTo(BigInteger.valueOf(157_500))
}
}
@Test
fun `GIVEN gasless resolves to token-paid fee WHEN calculate THEN fee returned without bump`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val expected = tokenPaidExtendedFee(fromStatus)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns expected.right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = null,
isGasless = true,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
assertThat(loaded.fee).isSameInstanceAs(expected)
}
}
@Test
fun `GIVEN gasless resolves to non-Ethereum fee WHEN calculate THEN fee returned without bump`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val expected = TransactionFeeExtended(
transactionFee = TransactionFee.Single(
normal = Fee.Common(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
),
),
feeTokenId = fromStatus.status.currency.id,
)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns expected.right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = null,
isGasless = true,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
assertThat(loaded.fee).isSameInstanceAs(expected)
}
}
@Test
fun `GIVEN gasless path returns Left WHEN calculate THEN error is propagated`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
@ -372,7 +509,7 @@ internal class CexSwapFeeCalculatorTest {
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val customWallet = mockk<UserWallet>(relaxed = true)
val expected = mockk<TransactionFeeExtended>(relaxed = true)
val expected = tokenPaidExtendedFee(fromStatus)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns expected.right()
@ -393,4 +530,19 @@ internal class CexSwapFeeCalculatorTest {
)
}
}
private fun tokenPaidExtendedFee(fromStatus: SwapCurrencyStatus): TransactionFeeExtended {
return TransactionFeeExtended(
transactionFee = TransactionFee.Single(
normal = Fee.Ethereum.TokenCurrency(
amount = Amount(currencySymbol = "USDT", value = BigDecimal("1.2"), decimals = 6),
gasLimit = BigInteger.valueOf(100_000),
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(50_000),
baseGas = BigInteger.valueOf(21_000),
),
),
feeTokenId = fromStatus.status.currency.id,
)
}
}