diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt index 3bf712d490..dfcd5fe08c 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt @@ -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 + } + } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index 38121250b6..fd89d99797 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -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(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(relaxed = true) - val expected = mockk(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, + ) + } } \ No newline at end of file