Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-26 17:56:10 +05:00
parent 132924c8c7
commit c3740a70c0
3 changed files with 269 additions and 5 deletions

View file

@ -63,6 +63,7 @@ import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.coroutines.runSuspendCatching
@ -1460,11 +1461,13 @@ internal class SwapInteractorImpl @Inject constructor(
raise(GetFeeError.DataError(error))
}
val approvalFee = getFeeUseCase(
transactionData = approvalTx,
userWallet = fromStatus.userWallet,
network = fromStatus.currency.network,
).bind()
val approvalFee = runSuspendCatching {
getFeeUseCase(
transactionData = approvalTx,
userWallet = fromStatus.userWallet,
network = fromStatus.currency.network,
).bind().patchIntegratedApprovalPriorityFee(INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL)
}.getOrElse { error -> raise(GetFeeError.DataError(error)) }
IntegratedApprovalData(
approvalTransaction = approvalTx,
@ -2445,6 +2448,8 @@ internal class SwapInteractorImpl @Inject constructor(
}
companion object {
private const val INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL = 115 // 15% increase
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD

View file

@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
@ -17,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for [SwapInteractorImpl.loadIntegratedApprovalData].
@ -185,6 +187,211 @@ internal class SwapInteractorImplLoadIntegratedApprovalDataTest : SwapInteractor
}
}
// region patchIntegratedApprovalPriorityFee — INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL (115 = +15% gas-price)
/**
* The loaded approval fee is patched via
* [com.tangem.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee] before being
* returned. Scales the **gas-price** fields (Legacy `gasPrice`; EIP1559
* `maxFeePerGas` and `priorityFee`) and the derived `amount` for [Fee.Ethereum] legs by 15%;
* The new `amount` is recomputed from `gasLimit * newGasPrice` shifted left by `decimals`,
* independent of the input amount value.
*/
@Test
fun `GIVEN Ethereum Legacy Single fee WHEN loaded THEN gasPrice and amount bumped by 15 percent`() = runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val initialFee = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.002")), // gasLimit * gasPrice / 1e18 = 100_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = initialFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert
val patched = result.singleNormal<Fee.Ethereum.Legacy>()
// gasLimit is NOT changed by this patch (it bumps gas-price only)
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(100_000))
// 20_000_000_000 * 115 / 100 = 23_000_000_000
assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000))
// amount recomputed from gasLimit * newGasPrice: 100_000 * 23e9 / 1e18 = 0.0023
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0023"))
// amount decimals preserved
assertThat(patched.amount.decimals).isEqualTo(18)
}
@Test
fun `GIVEN Ethereum EIP1559 Single fee WHEN loaded THEN gas-price fields bumped AND gasLimit untouched`() =
runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val initialFee = Fee.Ethereum.EIP1559(
amount = ethAmount(BigDecimal("0.0032")), // gasLimit * maxFeePerGas / 1e18 = 80_000 * 40e9 / 1e18
gasLimit = BigInteger.valueOf(80_000),
maxFeePerGas = BigInteger.valueOf(40_000_000_000),
priorityFee = BigInteger.valueOf(2_000_000_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = initialFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert
val patched = result.singleNormal<Fee.Ethereum.EIP1559>()
// gasLimit is NOT changed by this patch (it bumps gas-price only)
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(80_000))
// EIP1559 gas-price fields scaled by 115 / 100
assertThat(patched.maxFeePerGas).isEqualTo(BigInteger.valueOf(46_000_000_000))
assertThat(patched.priorityFee).isEqualTo(BigInteger.valueOf(2_300_000_000))
// amount recomputed from gasLimit * newMaxFeePerGas: 80_000 * 46e9 / 1e18 = 0.00368
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00368"))
}
@Test
fun `GIVEN Choosable Ethereum fee WHEN loaded THEN all three legs gasPrice bumped by 15 percent`() = runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val gasPrice = BigInteger.valueOf(20_000_000_000)
val choosable = TransactionFee.Choosable(
minimum = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.0008")), // 40_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(40_000),
gasPrice = gasPrice,
),
normal = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.0016")), // 80_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(80_000),
gasPrice = gasPrice,
),
priority = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.0024")), // 120_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(120_000),
gasPrice = gasPrice,
),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns choosable.right()
// Act
val patched = (loadLimited(fromStatus).feeOrFail() as TransactionFee.Choosable)
// Assert — every leg's gas-price scaled (gasPrice * 115 / 100 = 23e9), gasLimit unchanged
val newGasPrice = BigInteger.valueOf(23_000_000_000)
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(40_000))
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
assertThat((patched.normal as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(80_000))
assertThat((patched.normal as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
assertThat((patched.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(120_000))
assertThat((patched.priority as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
}
@Test
fun `GIVEN non-Ethereum approval fee WHEN loaded THEN fee is returned unchanged`() = runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val commonFee = Fee.Common(amount = ethAmount(BigDecimal("0.5"), decimals = 8))
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = commonFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert — non-Ethereum legs pass through untouched (same instance)
assertThat(result.singleNormal<Fee.Common>()).isSameInstanceAs(commonFee)
}
@Test
fun `GIVEN Ethereum Legacy fee with zero gasLimit WHEN loaded THEN gasPrice bumped and amount is zero`() =
runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val zeroGasFee = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000002")),
gasLimit = BigInteger.ZERO,
gasPrice = BigInteger.valueOf(20_000_000_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = zeroGasFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert — the gas-price path does NOT short-circuit on zero gasLimit (unlike the
// gas-limit path); gasPrice is still bumped and amount recomputes to gasLimit(0) * price = 0
val patched = result.singleNormal<Fee.Ethereum.Legacy>()
assertThat(patched.gasLimit).isEqualTo(BigInteger.ZERO)
assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000))
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal.ZERO)
}
@Test
fun `GIVEN Ethereum TokenCurrency approval fee WHEN loaded THEN returns Left DataError wrapping [REDACTED_TASK_KEY]`() =
runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val tokenFee = Fee.Ethereum.TokenCurrency(
amount = ethAmount(BigDecimal("0.001")),
gasLimit = BigInteger.valueOf(100_000),
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(50_000),
baseGas = BigInteger.valueOf(21_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = tokenFee).right()
// Act — the patch throws IllegalStateException, but the fee load is wrapped in
// runSuspendCatching ([REDACTED_TASK_KEY]) so it is caught and converted to Left(DataError)
// instead of crashing the DEX swap flow.
val result = loadLimited(fromStatus)
// Assert
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
val cause = (error as GetFeeError.DataError).cause
assertThat(cause).isInstanceOf(IllegalStateException::class.java)
assertThat(cause?.message).contains("[REDACTED_TASK_KEY]")
}
}
// endregion
private suspend fun loadLimited(fromStatus: com.tangem.domain.swap.models.SwapCurrencyStatus) =
sut.loadIntegratedApprovalData(
fromStatus = fromStatus,
spenderAddress = SPENDER,
approveType = ApproveType.LIMITED,
approvalAmount = SWAP_AMOUNT,
)
/** Unwraps a Right result into its [TransactionFee], failing the test on Left. */
private fun arrow.core.Either<GetFeeError, IntegratedApprovalData>.feeOrFail(): TransactionFee {
assertThat(isRight()).isTrue()
return getOrNull()!!.approvalFee
}
/** Unwraps a Right result into the `normal` leg of a [TransactionFee.Single], cast to [T]. */
private inline fun <reified T : Fee> arrow.core.Either<GetFeeError, IntegratedApprovalData>.singleNormal(): T {
return (feeOrFail() as TransactionFee.Single).normal as T
}
private fun ethAmount(value: BigDecimal, decimals: Int = 18): Amount = Amount(
currencySymbol = "ETH",
value = value,
decimals = decimals,
)
private companion object {
const val SPENDER = "0xSpender"
const val CONTRACT = "0xContract"

View file

@ -39,6 +39,22 @@ object BlockchainFeeUtils {
}
}
fun TransactionFee.patchIntegratedApprovalPriorityFee(increaseBy: Int): TransactionFee {
val patchedFee = when (this) {
is TransactionFee.Choosable -> {
copy(
normal = normal.increaseGasPrice(increaseBy),
minimum = minimum.increaseGasPrice(increaseBy),
priority = priority.increaseGasPrice(increaseBy),
)
}
is TransactionFee.Single -> copy(
normal = normal.increaseGasPrice(increaseBy),
)
}
return patchedFee
}
private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee {
return when (this) {
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
@ -81,4 +97,40 @@ object BlockchainFeeUtils {
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
}
}
/**
* Increase gasPrice/maxFeePerGas for Fee.Ethereum
*/
private fun Fee.increaseGasPrice(percent: Int): Fee {
if (this !is Fee.Ethereum) return this
return when (this) {
is Fee.Ethereum.EIP1559 -> {
val increasedGasPrice = maxFeePerGas.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT)
val increasedAmount = amount.copy(
value = gasLimit.toBigDecimal()
.multiply(increasedGasPrice.toBigDecimal())
.movePointLeft(amount.decimals),
)
copy(
amount = increasedAmount,
maxFeePerGas = increasedGasPrice,
priorityFee = priorityFee.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT),
)
}
is Fee.Ethereum.Legacy -> {
val increasedGasPrice = gasPrice.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT)
val increasedAmount = amount.copy(
value = gasLimit.toBigDecimal()
.multiply(increasedGasPrice.toBigDecimal())
.movePointLeft(amount.decimals),
)
copy(
amount = increasedAmount,
gasPrice = increasedGasPrice,
)
}
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
}
}
}