Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

@ -42,7 +42,6 @@ dependencies {
implementation(projects.domain.notifications)
api(projects.domain.networks)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)

View file

@ -4,10 +4,7 @@
<CurrentIssues>
<ID>BooleanPropertyNaming:SendTransactionUseCase.kt$SendTransactionUseCase$val linkedTerminal = cardSdkConfigRepository.isLinkedTerminal()</ID>
<ID>BooleanPropertyNaming:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$val current = isCurrentAddress(addressToValidate)</ID>
<ID>MultilineLambdaItParameter:AssociateAssetUseCase.kt$AssociateAssetUseCase${ val network = currency.network it.network.id == network.id &amp;&amp; it.network.derivationPath == network.derivationPath }</ID>
<ID>NamedArguments:SendTransactionUseCase.kt$SendTransactionUseCase$invoke(listOf(txData), userWallet, network, TransactionSender.MultipleTransactionSendMode.DEFAULT)</ID>
<ID>NamedArguments:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$validateAddressInternal( userWalletId, network, address, isCurrentAddress = { toValidate -&gt; currencyAddresses?.any { it.value == toValidate } ?: true }, )</ID>
<ID>NamedArguments:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$validateAddressInternal( userWalletId, network, address, isCurrentAddress = { toValidate -&gt; senderAddresses.any { it.address == toValidate } }, )</ID>
<ID>NullableBooleanCheck:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$currencyAddresses?.any { it.value == toValidate } ?: true</ID>
<ID>UnnecessaryLet:RetryIncompleteTransactionUseCase.kt$RetryIncompleteTransactionUseCase$let { raise(IncompleteTransactionError.SendError(it)) }</ID>
</CurrentIssues>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NullableToStringCall:SendTransactionError.kt$SendTransactionError$$code</ID>
</CurrentIssues>
<CurrentIssues/>
</SmellBaseline>

View file

@ -8,6 +8,7 @@ sealed class GetFeeError {
data object TronActivationError : BlockchainErrors()
data object KaspaZeroUtxo : BlockchainErrors()
data object SuiOneCoinRequired : BlockchainErrors()
data object TooLargeSolanaTransactionError : BlockchainErrors()
}
/**
@ -19,4 +20,15 @@ sealed class GetFeeError {
data object NotEnoughFunds : GaslessError()
data class DataError(val cause: Throwable?) : GaslessError()
}
/**
* Error for gas estimation with state override for ethereum like networks.
* Specifically overriding approval slot.
*/
data class EstimateOverrideError(
val blockchain: String,
val tokenSymbol: String,
val rpcProvider: String,
val error: String,
) : GetFeeError()
}

View file

@ -9,7 +9,7 @@ import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_C
import com.tangem.sdk.extensions.localizedDescriptionRes
fun Result.Failure.mapToFeeError(): GetFeeError {
return when (this.error) {
return when (val gasError = error) {
is BlockchainSdkError.Tron.AccountActivationError -> {
GetFeeError.BlockchainErrors.TronActivationError
}
@ -19,7 +19,15 @@ fun Result.Failure.mapToFeeError(): GetFeeError {
is BlockchainSdkError.Sui.OneSuiRequired -> {
GetFeeError.BlockchainErrors.SuiOneCoinRequired
}
else -> GetFeeError.DataError(this.error)
is BlockchainSdkError.Ethereum.EstimateOverrideError -> {
GetFeeError.EstimateOverrideError(
blockchain = gasError.blockchain,
tokenSymbol = gasError.tokenSymbol,
rpcProvider = gasError.rpcProvider,
error = gasError.underlyingError,
)
}
else -> GetFeeError.DataError(error)
}
}

View file

@ -46,7 +46,7 @@ class GetEthSpecificFeeUseCase(
val minimalFee = getEthLegacyFee(
gasPrice = gasPriceResult,
gasLimit = gasLimit,
decimals = cryptoCurrency.decimals,
decimals = blockchain.decimals(),
blockchain = blockchain,
)
@ -54,7 +54,7 @@ class GetEthSpecificFeeUseCase(
val normalFee = getEthLegacyFee(
gasPrice = normalGasPrice,
gasLimit = gasLimit,
decimals = cryptoCurrency.decimals,
decimals = blockchain.decimals(),
blockchain = blockchain,
)
@ -64,7 +64,7 @@ class GetEthSpecificFeeUseCase(
val priorityFee = getEthLegacyFee(
gasPrice = priorityGasPrice,
gasLimit = gasLimit,
decimals = cryptoCurrency.decimals,
decimals = blockchain.decimals(),
blockchain = blockchain,
)

View file

@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
@ -35,7 +36,13 @@ class GetFeeUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
) {
suspend operator fun invoke(userWallet: UserWallet, network: Network, transactionData: TransactionData) = either {
suspend operator fun invoke(
userWallet: UserWallet,
network: Network,
transactionData: TransactionData,
spenderAddress: String? = null,
isSimulateEstimation: Boolean = false,
) = either {
catch(
block = {
val transactionSender = if (userWallet is UserWallet.Cold &&
@ -48,8 +55,17 @@ class GetFeeUseCase(
network = network,
)
}
val result = transactionSender?.getFee(transactionData = transactionData)
?: error("Fee is null")
val isEthereumWalletManager = transactionSender is EthereumWalletManager
val result = if (isSimulateEstimation && spenderAddress != null && isEthereumWalletManager) {
transactionSender.estimateFeeWithOverride(
transactionData = transactionData,
spenderAddress = spenderAddress,
isSimulate = true,
)
} else {
transactionSender?.getFee(transactionData = transactionData)
?: error("Fee is null")
}
val maybeFee = when (result) {
is Result.Success -> result.data

View file

@ -199,7 +199,6 @@ class CreateAndSendGaslessTransactionUseCase(
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
transactionData = transactionData,
txHash = txHash,
contractAddress = transactionData.contractAddress,
)
return txHash

View file

@ -0,0 +1,66 @@
package com.tangem.domain.transaction.error
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.extensions.Result
import org.junit.Test
/**
* Tests for [mapToFeeError] the [Result.Failure] -> [GetFeeError] mapper. Focuses on the
* [REDACTED_TASK_KEY] addition: [BlockchainSdkError.Ethereum.EstimateOverrideError] must be mapped to
* [GetFeeError.EstimateOverrideError] field-by-field; all other errors fall through to
* [GetFeeError.DataError].
*/
internal class ErrorsMapperTest {
@Test
fun `GIVEN EstimateOverrideError THEN maps to GetFeeError EstimateOverrideError field by field`() {
val sdkError = BlockchainSdkError.Ethereum.EstimateOverrideError(
blockchain = "ethereum",
tokenSymbol = "USDT",
rpcProvider = "infura",
underlyingError = "execution reverted",
)
val result = Result.Failure(sdkError).mapToFeeError()
assertThat(result).isInstanceOf(GetFeeError.EstimateOverrideError::class.java)
val mapped = result as GetFeeError.EstimateOverrideError
assertThat(mapped.blockchain).isEqualTo("ethereum")
assertThat(mapped.tokenSymbol).isEqualTo("USDT")
assertThat(mapped.rpcProvider).isEqualTo("infura")
assertThat(mapped.error).isEqualTo("execution reverted")
}
@Test
fun `GIVEN TronActivationError THEN maps to TronActivationError`() {
// AccountActivationError is a class taking an int code, not an object.
val result = Result.Failure(BlockchainSdkError.Tron.AccountActivationError(code = 0)).mapToFeeError()
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
}
@Test
fun `GIVEN KaspaZeroUtxoError THEN maps to KaspaZeroUtxo`() {
val result = Result.Failure(BlockchainSdkError.Kaspa.ZeroUtxoError).mapToFeeError()
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
}
@Test
fun `GIVEN SuiOneSuiRequired THEN maps to SuiOneCoinRequired`() {
val result = Result.Failure(BlockchainSdkError.Sui.OneSuiRequired).mapToFeeError()
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.SuiOneCoinRequired)
}
@Test
fun `GIVEN unknown error THEN maps to DataError`() {
val sdkError = BlockchainSdkError.CustomError("boom")
val result = Result.Failure(sdkError).mapToFeeError()
assertThat(result).isInstanceOf(GetFeeError.DataError::class.java)
assertThat((result as GetFeeError.DataError).cause).isEqualTo(sdkError)
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.domain.transaction.usecase
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
@ -17,48 +19,185 @@ 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 org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.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.
* Covers two orthogonal pieces of the compiled-transaction overload:
* - The fee-source selection that chooses between the simulated `estimateFeeWithOverride` path and the legacy
* `getFee` path. The simulated estimation is selected only when ALL of these hold:
* - [GetFeeUseCase.invoke] is called with `isSimulateEstimation = true`
* - `spenderAddress != null`
* - the resolved transaction sender is an [EthereumWalletManager]
* - The Yield Mode gas-limit logic: 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 {
internal class GetFeeUseCaseTest {
private lateinit var walletManagersFacade: WalletManagersFacade
private lateinit var demoConfig: DemoConfig
private lateinit var useCase: GetFeeUseCase
private val walletManagersFacade: WalletManagersFacade = mockk()
private val demoConfig: DemoConfig = mockk()
private lateinit var walletManager: WalletManager
private lateinit var network: Network
private lateinit var userWallet: UserWallet.Hot
private lateinit var userWalletId: UserWalletId
private val useCase = GetFeeUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = demoConfig,
)
@Before
private val network: Network = mockk(relaxed = true)
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val userWallet: UserWallet.Hot = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val walletManager: WalletManager = mockk()
private val ethereumWalletManager: EthereumWalletManager = mockk()
private val plainWalletManager: WalletManager = mockk()
private val transactionData: TransactionData = mockk(relaxed = true)
private val expectedFee: TransactionFee = mockk(relaxed = true)
@BeforeEach
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, spenderAddress, isSimulateEstimation) — fee-source selection
@Test
fun `GIVEN simulate + spender + ethereum manager THEN estimateFee is used`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns ethereumWalletManager
coEvery {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulate = true,
)
} returns Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulateEstimation = true,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulate = true,
)
}
coVerify(exactly = 0) { ethereumWalletManager.getFee(transactionData = transactionData) }
}
@Test
fun `GIVEN simulate false THEN legacy getFee is used even for ethereum manager`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns ethereumWalletManager
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulateEstimation = false,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
coVerify(exactly = 0) {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = any(),
spenderAddress = any(),
isSimulate = any(),
)
}
}
@Test
fun `GIVEN null spender THEN legacy getFee is used even when simulate true`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns ethereumWalletManager
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = null,
isSimulateEstimation = true,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
coVerify(exactly = 0) {
ethereumWalletManager.estimateFeeWithOverride(
transactionData = any(),
spenderAddress = any(),
isSimulate = any(),
)
}
}
@Test
fun `GIVEN non-ethereum manager THEN legacy getFee is used even when simulate plus spender`() = runTest {
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns plainWalletManager
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns
Result.Success(expectedFee)
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
spenderAddress = SPENDER,
isSimulateEstimation = true,
)
assertThat(result).isEqualTo(expectedFee.right())
coVerify(exactly = 1) { plainWalletManager.getFee(transactionData = transactionData) }
}
@Test
fun `GIVEN getFee returns failure THEN error is mapped to GetFeeError`() = runTest {
val failure = Result.Failure(BlockchainSdkError.CustomError("boom"))
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
} returns plainWalletManager
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns failure
val result = useCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
}
// endregion
// region invoke(userWallet, network, transactionData) — Yield Mode gas-limit logic
@Test
@ -475,4 +614,8 @@ class GetFeeUseCaseTest {
}
// endregion
private companion object {
const val SPENDER = "0xSpender"
}
}

View file

@ -18,9 +18,9 @@ import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class ValidateWalletAddressUseCaseTest {
@ -34,14 +34,14 @@ internal class ValidateWalletAddressUseCaseTest {
private val userWalletId: UserWalletId = mockk()
private val network: Network = mockk()
@Before
@BeforeEach
fun setUp() {
mockkObject(BlockchainUtils)
every { BlockchainUtils.decodeRippleXAddress(any(), any()) } returns null
every { network.rawId } returns "ethereum"
}
@After
@AfterEach
fun tearDown() {
unmockkObject(BlockchainUtils)
}

View file

@ -22,9 +22,9 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.BigInteger
@ -45,7 +45,7 @@ class TokenFeeCalculatorTest {
private lateinit var mockUserWalletId: UserWalletId
private lateinit var mockTransactionData: TransactionData
@Before
@BeforeEach
fun setup() {
walletManagersFacade = mockk()
gaslessTransactionRepository = mockk()