Updated on 2026-08-14
This commit is contained in:
parent
d6a43b3b62
commit
c8ded3b7d5
2 changed files with 179 additions and 9 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue