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.Either
|
||||||
import arrow.core.raise.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.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||||
import com.tangem.domain.transaction.error.GetFeeError
|
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.EstimateFeeUseCase
|
||||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
|
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
|
||||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
|
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
|
||||||
|
|
@ -22,16 +24,15 @@ import java.math.BigDecimal
|
||||||
*
|
*
|
||||||
* Strategy is selected by [selectedFeeToken]:
|
* Strategy is selected by [selectedFeeToken]:
|
||||||
* - `null` → gasless. Calls [EstimateFeeForGaslessTxUseCase] which itself decides whether to use
|
* - `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
|
* a native or token fee. When it resolves to a **native** Ethereum fee, the 5% gas-limit bump is
|
||||||
* overload 1.
|
* 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 + token currency → calls [EstimateFeeForTokenUseCase]. **No gas-limit bump.**
|
||||||
* - non-null + native (coin) currency → calls [EstimateFeeUseCase]. **The 5% gas-limit bump is
|
* - non-null + native (coin) currency → calls [EstimateFeeUseCase]. **The 5% gas-limit bump is
|
||||||
* applied via [patchEthGasLimitForSwap]** for parity with `loadFeeForSwapTransaction` overload 2.
|
* 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.
|
* 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
|
* This is the single CEX fee path: `SwapInteractorImpl.loadSwapFee` delegates here for CEX swaps.
|
||||||
* original code is intentionally retained alongside this calculator until the caller is migrated
|
|
||||||
* to delegate to it (the migration is deferred — see plan).
|
|
||||||
*/
|
*/
|
||||||
class CexSwapFeeCalculator(
|
class CexSwapFeeCalculator(
|
||||||
private val estimateFeeUseCase: EstimateFeeUseCase,
|
private val estimateFeeUseCase: EstimateFeeUseCase,
|
||||||
|
|
@ -54,13 +55,14 @@ class CexSwapFeeCalculator(
|
||||||
val transactionFeeResult: TransactionFeeResult = if (isGasless) {
|
val transactionFeeResult: TransactionFeeResult = if (isGasless) {
|
||||||
when {
|
when {
|
||||||
selectedFeeToken == null -> {
|
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(
|
val feeExtended = estimateFeeForGaslessTxUseCase(
|
||||||
amount = amount,
|
amount = amount,
|
||||||
userWallet = userWallet,
|
userWallet = userWallet,
|
||||||
sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status,
|
sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||||
).bind()
|
).bind()
|
||||||
TransactionFeeResult.LoadedExtended(feeExtended)
|
TransactionFeeResult.LoadedExtended(feeExtended.patchIfNativeEthereumFee())
|
||||||
}
|
}
|
||||||
selectedFeeToken.currency is CryptoCurrency.Token -> {
|
selectedFeeToken.currency is CryptoCurrency.Token -> {
|
||||||
// Explicit gasless-token path — overload 1 in SwapInteractorImpl. No gas-limit bump.
|
// Explicit gasless-token path — overload 1 in SwapInteractorImpl. No gas-limit bump.
|
||||||
|
|
@ -94,4 +96,20 @@ class CexSwapFeeCalculator(
|
||||||
|
|
||||||
CexFeeResult(transactionFee = transactionFeeResult)
|
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.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
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.error.GetFeeError
|
||||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||||
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
|
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
|
||||||
|
|
@ -94,7 +95,7 @@ internal class CexSwapFeeCalculatorTest {
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN null selectedFeeToken WHEN calculate THEN delegates to estimateFeeForGaslessTxUseCase`() = runTest {
|
fun `GIVEN null selectedFeeToken WHEN calculate THEN delegates to estimateFeeForGaslessTxUseCase`() = runTest {
|
||||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||||
val expected = mockk<TransactionFeeExtended>(relaxed = true)
|
val expected = tokenPaidExtendedFee(fromStatus)
|
||||||
coEvery {
|
coEvery {
|
||||||
estimateFeeForGaslessTxUseCase(any(), any(), any())
|
estimateFeeForGaslessTxUseCase(any(), any(), any())
|
||||||
} returns expected.right()
|
} 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
|
@Test
|
||||||
fun `GIVEN gasless path returns Left WHEN calculate THEN error is propagated`() = runTest {
|
fun `GIVEN gasless path returns Left WHEN calculate THEN error is propagated`() = runTest {
|
||||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||||
|
|
@ -372,7 +509,7 @@ internal class CexSwapFeeCalculatorTest {
|
||||||
runTest {
|
runTest {
|
||||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||||
val customWallet = mockk<UserWallet>(relaxed = true)
|
val customWallet = mockk<UserWallet>(relaxed = true)
|
||||||
val expected = mockk<TransactionFeeExtended>(relaxed = true)
|
val expected = tokenPaidExtendedFee(fromStatus)
|
||||||
coEvery {
|
coEvery {
|
||||||
estimateFeeForGaslessTxUseCase(any(), any(), any())
|
estimateFeeForGaslessTxUseCase(any(), any(), any())
|
||||||
} returns expected.right()
|
} 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