Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-11 19:28:42 +05:00
parent 0a44bcfc70
commit 81ed31befb
3 changed files with 542 additions and 2 deletions

View file

@ -2,11 +2,15 @@ package com.tangem.domain.transaction.usecase
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
@ -15,7 +19,11 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.mapToFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
/**
* Use case to get transaction fee
@ -47,7 +55,7 @@ class GetFeeUseCase(
is Result.Success -> result.data
is Result.Failure -> raise(result.mapToFeeError())
}
maybeFee
maybeFee.fixYieldSupplyGasLimit(transactionData = transactionData)
},
catch = {
raise(GetFeeError.DataError(it))
@ -115,4 +123,58 @@ class GetFeeUseCase(
)
},
)
private fun TransactionFee.fixYieldSupplyGasLimit(transactionData: TransactionData): TransactionFee {
val uncompiledTransactionData = transactionData as? TransactionData.Uncompiled ?: return this
val ethereumExtras = uncompiledTransactionData.extras as? EthereumTransactionExtras ?: return this
return if (ethereumExtras.callData is EthereumYieldSupplySendCallData) {
val patchedFee = when (this) {
is TransactionFee.Choosable -> {
copy(
normal = normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY),
minimum = minimum.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY),
priority = priority.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY),
)
}
is TransactionFee.Single -> copy(
normal = normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY),
)
}
TangemLogger.withTag("GAS_FEE_USECASE").i("Fee for Yield Mode adjusted: $patchedFee")
patchedFee
} else {
TangemLogger.withTag("GAS_FEE_USECASE").i("Fee as is: $this")
this
}
}
/**
* Increase gasLimit for Fee.Ethereum
*/
private fun Fee.increaseGasLimitBy(percent: BigInteger): Fee {
if (this !is Fee.Ethereum) return this
val gasLimit = gasLimit
if (gasLimit == BigInteger.ZERO || amount.value.isNullOrZero()) return this
val increasedGasPrice = amount.value?.movePointRight(amount.decimals)
?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP)
val increasedGasLimit = gasLimit
.multiply(percent)
.divide(HUNDRED_PERCENT)
val increasedAmount = this.amount.copy(
value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(amount.decimals),
)
return when (this) {
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
}
}
private companion object {
private val HUNDRED_PERCENT = 100.toBigInteger() // base 100%
val INCREASE_GAS_LIMIT_FOR_SUPPLY = 140.toBigInteger() // 40% increase [there is also in Yield FeeExtensions.kt]
}
}

View file

@ -0,0 +1,478 @@
package com.tangem.domain.transaction.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.math.BigDecimal
import java.math.BigInteger
/**
* Unit tests for [GetFeeUseCase].
*
* Focus is the Yield Mode gas-limit logic introduced on this branch
* (uncompiled Ethereum transactions whose call data is [EthereumYieldSupplySendCallData]
* get their gas limit increased by 40%), plus error mapping, null/exception handling,
* demo card routing, and crypto-currency-to-amount conversion in the second overload.
*/
class GetFeeUseCaseTest {
private lateinit var walletManagersFacade: WalletManagersFacade
private lateinit var demoConfig: DemoConfig
private lateinit var useCase: GetFeeUseCase
private lateinit var walletManager: WalletManager
private lateinit var network: Network
private lateinit var userWallet: UserWallet.Hot
private lateinit var userWalletId: UserWalletId
@Before
fun setup() {
walletManagersFacade = mockk()
demoConfig = mockk()
useCase = GetFeeUseCase(walletManagersFacade, demoConfig)
walletManager = mockk()
network = mockk()
userWalletId = mockk()
userWallet = mockk<UserWallet.Hot>()
every { demoConfig.isDemoCardId(any()) } returns false
every { userWallet.walletId } returns userWalletId
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns walletManager
}
// region invoke(userWallet, network, transactionData) — Yield Mode gas-limit logic
@Test
fun `yield supply uncompiled eth tx increases gas limit by 40 percent for Choosable fee`() = runTest {
// Given
val txData = yieldSupplyTransactionData()
val original = TransactionFee.Choosable(
minimum = eip1559Fee(gasLimit = BigInteger("10000"), value = BigDecimal("0.001")),
normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")),
priority = legacyFee(gasLimit = BigInteger("30000"), value = BigDecimal("0.003")),
)
coEvery { walletManager.getFee(txData) } returns Result.Success(original)
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.isRight()).isTrue()
val fee = result.getOrNull().requireAs<TransactionFee.Choosable>()
assertGasLimitIncreased(
patched = fee.normal,
expectedGasLimit = BigInteger("29400"), // 21000 * 140 / 100
expectedValue = BigDecimal("0.00294"),
)
assertGasLimitIncreased(
patched = fee.minimum,
expectedGasLimit = BigInteger("14000"), // 10000 * 140 / 100
expectedValue = BigDecimal("0.0014"),
)
assertGasLimitIncreased(
patched = fee.priority,
expectedGasLimit = BigInteger("42000"), // 30000 * 140 / 100
expectedValue = BigDecimal("0.0042"),
)
}
@Test
fun `yield supply uncompiled eth tx increases gas limit by 40 percent for Single fee`() = runTest {
// Given
val txData = yieldSupplyTransactionData()
val original = TransactionFee.Single(
normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")),
)
coEvery { walletManager.getFee(txData) } returns Result.Success(original)
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.isRight()).isTrue()
val fee = result.getOrNull().requireAs<TransactionFee.Single>()
assertGasLimitIncreased(
patched = fee.normal,
expectedGasLimit = BigInteger("29400"),
expectedValue = BigDecimal("0.00294"),
)
}
@Test
fun `compiled tx is returned unchanged even when fee is ethereum`() = runTest {
// Given
val txData = mockk<TransactionData.Compiled>()
val original = TransactionFee.Single(
normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")),
)
coEvery { walletManager.getFee(txData) } returns Result.Success(original)
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.getOrNull()).isEqualTo(original)
}
@Test
fun `non ethereum extras returns fee unchanged`() = runTest {
// Given
val extras = mockk<TransactionExtras>()
val txData = uncompiledTransactionData(extras)
val original = TransactionFee.Single(
normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")),
)
coEvery { walletManager.getFee(txData) } returns Result.Success(original)
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.getOrNull()).isEqualTo(original)
}
@Test
fun `non yield supply call data returns fee unchanged`() = runTest {
// Given
val extras = EthereumTransactionExtras(callData = mockk<SmartContractCallData>())
val txData = uncompiledTransactionData(extras)
val original = TransactionFee.Single(
normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")),
)
coEvery { walletManager.getFee(txData) } returns Result.Success(original)
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.getOrNull()).isEqualTo(original)
}
@Test
fun `yield supply with non ethereum fee returns fee unchanged`() = runTest {
// Given — call data matches, but the fee is not Fee.Ethereum
val txData = yieldSupplyTransactionData()
val nonEthFee = Fee.Common(amount = stubAmount(BigDecimal("0.5")))
val original = TransactionFee.Single(normal = nonEthFee)
coEvery { walletManager.getFee(txData) } returns Result.Success(original)
// When
val result = useCase(userWallet, network, txData)
// Then — gas-limit logic is a no-op for non-Ethereum fees
val fee = result.getOrNull().requireAs<TransactionFee.Single>()
assertThat(fee.normal).isEqualTo(nonEthFee)
}
@Test
fun `yield supply with token currency fee surfaces as DataError`() = runTest {
// Given — increaseGasLimitBy throws for Fee.Ethereum.TokenCurrency (handled in [REDACTED_TASK_KEY])
val txData = yieldSupplyTransactionData()
val tokenFee = Fee.Ethereum.TokenCurrency(
amount = stubAmount(BigDecimal("0.0021")),
gasLimit = BigInteger("21000"),
coinPriceInToken = BigInteger("1000"),
feeTransferGasLimit = BigInteger("60000"),
baseGas = BigInteger("21000"),
)
val original = TransactionFee.Single(normal = tokenFee)
coEvery { walletManager.getFee(txData) } returns Result.Success(original)
// When
val result = useCase(userWallet, network, txData)
// Then — the thrown error is caught and mapped to DataError
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
}
// endregion
// region invoke(userWallet, network, transactionData) — error / null / exception handling
@Test
fun `result failure is mapped to fee error`() = runTest {
// Given
val txData = yieldSupplyTransactionData()
val failure = Result.Failure(BlockchainSdkError.Tron.AccountActivationError(code = 1))
coEvery { walletManager.getFee(txData) } returns failure
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
}
@Test
fun `null wallet manager produces DataError`() = runTest {
// Given
val txData = yieldSupplyTransactionData()
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns null
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.isLeft()).isTrue()
val error = result.leftOrNull().requireAs<GetFeeError.DataError>()
assertThat(error.cause?.message).isEqualTo("Fee is null")
}
@Test
fun `exception in getFee produces DataError`() = runTest {
// Given
val txData = yieldSupplyTransactionData()
val boom = IllegalStateException("boom")
coEvery { walletManager.getFee(txData) } throws boom
// When
val result = useCase(userWallet, network, txData)
// Then
assertThat(result.isLeft()).isTrue()
val error = result.leftOrNull().requireAs<GetFeeError.DataError>()
assertThat(error.cause).isEqualTo(boom)
}
@Test
fun `demo cold card routes through wallet manager for first overload`() = runTest {
// Given
val coldWallet = mockk<UserWallet.Cold>()
every { coldWallet.walletId } returns userWalletId
every { coldWallet.scanResponse.card.cardId } returns "DEMO_CARD"
every { demoConfig.isDemoCardId("DEMO_CARD") } returns true
// DemoTransactionSender may access walletManager.wallet.blockchain when producing stub fees
val demoWallet = mockk<com.tangem.blockchain.common.Wallet>(relaxed = true)
every { demoWallet.blockchain } returns com.tangem.blockchain.common.Blockchain.Ethereum
every { walletManager.wallet } returns demoWallet
val txData = yieldSupplyTransactionData()
// When
useCase(coldWallet, network, txData)
// Then — demo sender is built from a wallet manager obtained via the facade
coVerify { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) }
}
// endregion
// region invoke(amount, destination, userWallet, cryptoCurrency)
@Test
fun `second overload converts coin to amount and returns fee`() = runTest {
// Given
val coin = mockk<CryptoCurrency.Coin>()
every { coin.network } returns network
every { coin.symbol } returns "ETH"
every { coin.decimals } returns 18
val expectedFee = TransactionFee.Single(
normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")),
)
val amountSlot = slot<Amount>()
coEvery {
walletManagersFacade.getFee(
amount = capture(amountSlot),
destination = "dest",
userWalletId = userWalletId,
network = network,
)
} returns Result.Success(expectedFee)
// When
val result = useCase.invoke(
amount = BigDecimal("1.5"),
destination = "dest",
userWallet = userWallet,
cryptoCurrency = coin,
)
// Then
assertThat(result.getOrNull()).isEqualTo(expectedFee)
val captured = amountSlot.captured
assertThat(captured.type).isEqualTo(AmountType.Coin)
assertThat(captured.currencySymbol).isEqualTo("ETH")
assertThat(captured.decimals).isEqualTo(18)
assertThat(captured.value).isEqualTo(BigDecimal("1.5"))
}
@Test
fun `second overload converts token to amount with token type`() = runTest {
// Given
val token = mockk<CryptoCurrency.Token>()
every { token.network } returns network
every { token.symbol } returns "USDC"
every { token.decimals } returns 6
every { token.contractAddress } returns "0xUSDC"
val expectedFee = TransactionFee.Single(
normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")),
)
val amountSlot = slot<Amount>()
coEvery {
walletManagersFacade.getFee(
amount = capture(amountSlot),
destination = "dest",
userWalletId = userWalletId,
network = network,
)
} returns Result.Success(expectedFee)
// When
val result = useCase.invoke(
amount = BigDecimal("100"),
destination = "dest",
userWallet = userWallet,
cryptoCurrency = token,
)
// Then
assertThat(result.getOrNull()).isEqualTo(expectedFee)
val captured = amountSlot.captured
val type = captured.type.requireAs<AmountType.Token>()
assertThat(type.token.contractAddress).isEqualTo("0xUSDC")
assertThat(type.token.symbol).isEqualTo("USDC")
assertThat(type.token.decimals).isEqualTo(6)
}
@Test
fun `second overload maps result failure to fee error`() = runTest {
// Given
val coin = mockk<CryptoCurrency.Coin>()
every { coin.network } returns network
every { coin.symbol } returns "KAS"
every { coin.decimals } returns 8
coEvery {
walletManagersFacade.getFee(
amount = any(),
destination = any(),
userWalletId = userWalletId,
network = network,
)
} returns Result.Failure(BlockchainSdkError.Kaspa.ZeroUtxoError)
// When
val result = useCase.invoke(
amount = BigDecimal("1"),
destination = "dest",
userWallet = userWallet,
cryptoCurrency = coin,
)
// Then
assertThat(result.leftOrNull()).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
}
@Test
fun `second overload null fee produces DataError`() = runTest {
// Given
val coin = mockk<CryptoCurrency.Coin>()
every { coin.network } returns network
every { coin.symbol } returns "ETH"
every { coin.decimals } returns 18
coEvery {
walletManagersFacade.getFee(
amount = any(),
destination = any(),
userWalletId = userWalletId,
network = network,
)
} returns null
// When
val result = useCase.invoke(
amount = BigDecimal("1"),
destination = "dest",
userWallet = userWallet,
cryptoCurrency = coin,
)
// Then
val error = result.leftOrNull().requireAs<GetFeeError.DataError>()
assertThat(error.cause?.message).isEqualTo("Fee is null")
}
// endregion
// region helpers
private fun yieldSupplyTransactionData(): TransactionData.Uncompiled {
val callData = mockk<EthereumYieldSupplySendCallData>()
return uncompiledTransactionData(EthereumTransactionExtras(callData = callData))
}
private fun uncompiledTransactionData(extras: TransactionExtras): TransactionData.Uncompiled {
return TransactionData.Uncompiled(
amount = stubAmount(BigDecimal.ONE),
fee = null,
sourceAddress = "src",
destinationAddress = "dest",
extras = extras,
)
}
private fun eip1559Fee(gasLimit: BigInteger, value: BigDecimal): Fee.Ethereum.EIP1559 {
return Fee.Ethereum.EIP1559(
amount = stubAmount(value),
gasLimit = gasLimit,
maxFeePerGas = BigInteger("50000000000"),
priorityFee = BigInteger("1000000000"),
)
}
private fun legacyFee(gasLimit: BigInteger, value: BigDecimal): Fee.Ethereum.Legacy {
return Fee.Ethereum.Legacy(
amount = stubAmount(value),
gasLimit = gasLimit,
gasPrice = BigInteger("50000000000"),
)
}
private fun stubAmount(value: BigDecimal): Amount = Amount(
currencySymbol = "ETH",
value = value,
decimals = 18,
type = AmountType.Coin,
)
private fun assertGasLimitIncreased(patched: Fee, expectedGasLimit: BigInteger, expectedValue: BigDecimal) {
val eth = patched.requireAs<Fee.Ethereum>()
assertThat(eth.gasLimit).isEqualTo(expectedGasLimit)
val actualValue = requireNotNull(eth.amount.value) { "Fee amount value must not be null" }
assertThat(actualValue.compareTo(expectedValue)).isEqualTo(0)
}
private inline fun <reified T> Any?.requireAs(): T {
val value = this
assertThat(value).isInstanceOf(T::class.java)
return value as T
}
// endregion
}

View file

@ -6,7 +6,7 @@ import java.math.BigInteger
import java.math.RoundingMode
private val HUNDRED_PERCENT = 100.toBigInteger() // base 100%
val INCREASE_GAS_LIMIT_FOR_SUPPLY = 140.toBigInteger() // 20% increase
val INCREASE_GAS_LIMIT_FOR_SUPPLY = 140.toBigInteger() // 40% increase [there is also in GetFeeUseCase]
fun Fee.fixFee(cryptoCurrency: CryptoCurrency, gasLimit: BigInteger): Fee = when (this) {
is Fee.Ethereum.Legacy -> copy(