Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-25 14:57:55 +05:00
commit a335e8d8f4
21 changed files with 541 additions and 63 deletions

View file

@ -1,13 +1,23 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.blockaid"
}
dependencies {
/* Project - Domain */
/** Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.blockaid.models)
/* Other */
/** Tangem SDK */
implementation(tangemDeps.blockchain)
/** Other */
implementation(deps.moshi.adapters)
}

View file

@ -0,0 +1,7 @@
package com.domain.blockaid.models.transaction
import java.math.BigInteger
data class GasEstimationResult(
val estimatedGasList: List<BigInteger>,
)

View file

@ -0,0 +1,14 @@
package com.tangem.domain.blockaid
import arrow.core.Either
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.models.currency.CryptoCurrency
interface BlockAidGasEstimate {
suspend fun getGasEstimation(
cryptoCurrency: CryptoCurrency,
transactionDataList: List<TransactionData.Uncompiled>,
): Either<Throwable, GasEstimationResult>
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.transaction
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -18,4 +19,6 @@ interface FeeRepository {
cryptoCurrency: CryptoCurrency,
transactionData: TransactionData,
): TransactionFee
suspend fun getEthereumFeeWithoutGas(userWallet: UserWallet, cryptoCurrency: CryptoCurrency): Fee.Ethereum
}

View file

@ -18,12 +18,15 @@ dependencies {
implementation(projects.domain.transaction.models)
implementation(projects.domain.transaction)
implementation(projects.domain.legacy)
implementation(projects.domain.blockaid.models)
implementation(projects.domain.blockaid)
/** Tandem SDK */
implementation(tangemDeps.blockchain)
/** Other */
implementation(deps.arrow.core)
implementation(deps.timber)
/** tests */
testImplementation(projects.common.test)

View file

@ -5,23 +5,85 @@ import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyEnterCallData
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.error.FeeErrorResolver
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.utils.extensions.isSingleItem
import timber.log.Timber
import java.math.BigInteger
class YieldSupplyEstimateEnterFeeUseCase(
private val feeRepository: FeeRepository,
private val feeErrorResolver: FeeErrorResolver,
private val blockAidGasEstimate: BlockAidGasEstimate,
) {
suspend operator fun invoke(
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
transactionDataList: List<TransactionData.Uncompiled>,
): Either<GetFeeError, List<TransactionData.Uncompiled>> = Either.catch {
val withCalculatedFee = transactionDataList.filter {
if (transactionDataList.isSingleItem()) {
transactionDataList.map { transaction ->
transaction.copy(
fee = feeRepository.calculateFee(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionData = transaction,
).normal,
)
}
} else {
val estimatedFees = estimateFeeWithBlockAid(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = transactionDataList,
)
if (estimatedFees.isNullOrEmpty()) {
// Fallback in case block aid returns error
estimateFeeWithStatic(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = transactionDataList,
)
} else {
estimatedFees
}
}
}.mapLeft(feeErrorResolver::resolve)
private suspend fun estimateFeeWithBlockAid(
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
transactionDataList: List<TransactionData.Uncompiled>,
): List<TransactionData.Uncompiled>? {
val estimatedFees = blockAidGasEstimate.getGasEstimation(
cryptoCurrency = cryptoCurrency,
transactionDataList = transactionDataList,
).onLeft(Timber::e).getOrNull() ?: return null
if (estimatedFees.estimatedGasList.isEmpty()) return null
val fee = feeRepository.getEthereumFeeWithoutGas(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
)
return transactionDataList.zip(estimatedFees.estimatedGasList) { transaction, estimatedGas ->
transaction.copy(fee = fee.fixFee(cryptoCurrency, estimatedGas))
}
}
private suspend fun estimateFeeWithStatic(
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
transactionDataList: List<TransactionData.Uncompiled>,
): List<TransactionData.Uncompiled> {
val withCalculatedFees = transactionDataList.filter {
(it.extras as? EthereumTransactionExtras)?.callData !is EthereumYieldSupplyEnterCallData
}.map { transaction ->
transaction.copy(
@ -33,38 +95,30 @@ class YieldSupplyEstimateEnterFeeUseCase(
)
}
val withEstimatedCalculatedFee = transactionDataList.filter {
val calculatedFee = withCalculatedFees.firstOrNull()?.fee ?: error("Must be any calculated fee")
val withEstimatedFees = transactionDataList.filter {
(it.extras as? EthereumTransactionExtras)?.callData is EthereumYieldSupplyEnterCallData
}.map { transaction ->
if (transactionDataList.isSingleItem()) {
transaction.copy(
fee = feeRepository.calculateFee(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionData = transaction,
).normal,
)
} else {
transaction.copy(fee = withCalculatedFee.firstOrNull()?.fee?.fixFee(cryptoCurrency))
}
transaction.copy(fee = calculatedFee.fixFee(cryptoCurrency, ETHEREUM_CONSTANT_GAS_LIMIT))
}
// Transactions order must be preserved
withCalculatedFee + withEstimatedCalculatedFee
}.mapLeft(feeErrorResolver::resolve)
// First return calculated fees then estimated
return withCalculatedFees + withEstimatedFees
}
private fun Fee.fixFee(cryptoCurrency: CryptoCurrency) = when (this) {
private fun Fee.fixFee(cryptoCurrency: CryptoCurrency, gasLimit: BigInteger) = when (this) {
is Fee.Ethereum.Legacy -> copy(
gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT,
gasLimit = gasLimit,
amount = amount.copy(
value = gasPrice.multiply(ETHEREUM_CONSTANT_GAS_LIMIT)
value = gasPrice.multiply(gasLimit)
.toBigDecimal().movePointLeft(cryptoCurrency.decimals),
),
)
is Fee.Ethereum.EIP1559 -> copy(
gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT,
gasLimit = gasLimit,
amount = amount.copy(
value = maxFeePerGas.multiply(ETHEREUM_CONSTANT_GAS_LIMIT)
value = maxFeePerGas.multiply(gasLimit)
.toBigDecimal().movePointLeft(cryptoCurrency.decimals),
),
)
@ -73,6 +127,6 @@ class YieldSupplyEstimateEnterFeeUseCase(
private companion object {
// Using constant gas limit to avoid fee calculation errors when contract address is not deployed yet
val ETHEREUM_CONSTANT_GAS_LIMIT = 350_000.toBigInteger()
val ETHEREUM_CONSTANT_GAS_LIMIT = 500_000.toBigInteger()
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.domain.yield.supply
import arrow.core.Either
import arrow.core.right
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.google.common.truth.Truth
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Blockchain
@ -10,6 +12,7 @@ import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderF
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
@ -17,9 +20,7 @@ import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.error.FeeErrorResolver
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@ -30,7 +31,8 @@ import java.math.BigInteger
class YieldSupplyEstimateEnterFeeUseCaseTest {
private val feeRepository: FeeRepository = mockk()
private val feeErrorResolver: FeeErrorResolver = mockk()
private val useCase = YieldSupplyEstimateEnterFeeUseCase(feeRepository, feeErrorResolver)
private val blockAidGasEstimate: BlockAidGasEstimate = mockk()
private val useCase = YieldSupplyEstimateEnterFeeUseCase(feeRepository, feeErrorResolver, blockAidGasEstimate)
private val userWallet: UserWallet = mockk()
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
@ -72,45 +74,57 @@ class YieldSupplyEstimateEnterFeeUseCaseTest {
@Test
fun `test enter transaction uses constant gas limit Legacy`() = runTest {
val fee = TransactionFee.Single(ethLegacyFee())
val deployTx = getDeployTx()
val enterTx = getEnterTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(emptyList()).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(
getDeployTx(),
getEnterTx(),
),
transactionDataList = listOf(deployTx, enterTx),
)
Truth.assertThat(result.isRight()).isTrue()
val txs = (result as Either.Right).value
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.calculateFee(any(), any(), deployTx)
}
coVerify(inverse = true) {
feeRepository.calculateFee(any(), any(), enterTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.Legacy
val enterFee = txs.last().fee as Fee.Ethereum.Legacy
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(500_000))
}
@Test
fun `test not enter transaction uses gas limit Legacy`() = runTest {
val fee = TransactionFee.Single(ethLegacyFee())
val deployTx = getDeployTx()
val approveTx = getApproveTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(emptyList()).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee)
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(
getDeployTx(),
getApproveTx(),
),
transactionDataList = listOf(deployTx, approveTx),
)
Truth.assertThat(result.isRight()).isTrue()
val txs = (result as Either.Right).value
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.calculateFee(any(), any(), deployTx)
feeRepository.calculateFee(any(), any(), approveTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.Legacy
val approveFee = txs.last().fee as Fee.Ethereum.Legacy
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000))
@ -120,69 +134,88 @@ class YieldSupplyEstimateEnterFeeUseCaseTest {
@Test
fun `test enter transaction with wrong order uses gas limit Legacy`() = runTest {
val fee = TransactionFee.Single(ethLegacyFee())
val enterTx = getEnterTx()
val deployTx = getDeployTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(emptyList()).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee)
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(
getEnterTx(),
getDeployTx(),
),
transactionDataList = listOf(enterTx, deployTx),
)
Truth.assertThat(result.isRight()).isTrue()
val txs = (result as Either.Right).value
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.calculateFee(any(), any(), deployTx)
}
coVerify(inverse = true) {
feeRepository.calculateFee(any(), any(), enterTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.Legacy
val enterFee = txs.last().fee as Fee.Ethereum.Legacy
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(500_000))
}
@Test
fun `test enter transaction uses constant gas limit Eip1559`() = runTest {
val fee = TransactionFee.Single(ethEip1559Fee())
val deployTx = getDeployTx()
val enterTx = getEnterTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(emptyList()).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(
getDeployTx(),
getEnterTx(),
),
transactionDataList = listOf(deployTx, enterTx),
)
Truth.assertThat(result.isRight()).isTrue()
val txs = (result as Either.Right).value
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.calculateFee(any(), any(), deployTx)
}
coVerify(inverse = true) {
feeRepository.calculateFee(any(), any(), enterTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.EIP1559
val enterFee = txs.last().fee as Fee.Ethereum.EIP1559
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(500_000))
}
@Test
fun `test not enter transaction uses gas limit Eip1559`() = runTest {
val fee = TransactionFee.Single(ethEip1559Fee())
val deployTx = getDeployTx()
val approveTx = getApproveTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(emptyList()).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee)
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(
getDeployTx(),
getApproveTx(),
),
transactionDataList = listOf(deployTx, approveTx),
)
Truth.assertThat(result.isRight()).isTrue()
val txs = (result as Either.Right).value
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.calculateFee(any(), any(), deployTx)
feeRepository.calculateFee(any(), any(), approveTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.EIP1559
val approveFee = txs.last().fee as Fee.Ethereum.EIP1559
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000))
@ -192,25 +225,137 @@ class YieldSupplyEstimateEnterFeeUseCaseTest {
@Test
fun `test enter transaction with wrong order uses gas limit Eip1559`() = runTest {
val fee = TransactionFee.Single(ethEip1559Fee())
val enterTx = getEnterTx()
val deployTx = getDeployTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(emptyList()).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee)
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(
getEnterTx(),
getDeployTx(),
),
transactionDataList = listOf(enterTx, deployTx),
)
Truth.assertThat(result.isRight()).isTrue()
val txs = (result as Either.Right).value
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.calculateFee(any(), any(), deployTx)
}
coVerify(inverse = true) {
feeRepository.calculateFee(any(), any(), enterTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.EIP1559
val enterFee = txs.last().fee as Fee.Ethereum.EIP1559
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(500_000))
}
@Test
fun `test single transaction with fee Legacy`() = runTest {
val fee = TransactionFee.Single(ethLegacyFee())
val enterTx = getEnterTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(emptyList()).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(enterTx),
)
Truth.assertThat(result.isRight()).isTrue()
coVerify(ordering = Ordering.ORDERED) {
feeRepository.calculateFee(any(), any(), enterTx)
}
coVerify(inverse = true) {
blockAidGasEstimate.getGasEstimation(any(), any())
}
val txs = (result as Either.Right).value
val enterFee = txs.first().fee as Fee.Ethereum.Legacy
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000))
}
@Test
fun `test enter transaction uses block aid Eip1559`() = runTest {
val fee = ethEip1559Fee()
val deployTx = getDeployTx()
val approveTx = getApproveTx()
val enterTx = getEnterTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(
estimatedGasList = listOf(1_000.toBigInteger(), 2_000.toBigInteger(), 3_000.toBigInteger()),
).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returns mockk()
coEvery { feeRepository.getEthereumFeeWithoutGas(any(), any()) } returns fee
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(deployTx, approveTx, enterTx),
)
Truth.assertThat(result.isRight()).isTrue()
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.getEthereumFeeWithoutGas(any(), any())
}
coVerify(inverse = true) {
feeRepository.calculateFee(any(), any(), deployTx)
feeRepository.calculateFee(any(), any(), approveTx)
feeRepository.calculateFee(any(), any(), enterTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.EIP1559
val approveFee = txs[1].fee as Fee.Ethereum.EIP1559
val enterFee = txs.last().fee as Fee.Ethereum.EIP1559
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_000))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(3_000))
}
@Test
fun `test enter transaction uses block aid Legacy`() = runTest {
val fee = ethLegacyFee()
val deployTx = getDeployTx()
val approveTx = getApproveTx()
val enterTx = getEnterTx()
coEvery { blockAidGasEstimate.getGasEstimation(any(), any()) } returns GasEstimationResult(
estimatedGasList = listOf(1_000.toBigInteger(), 2_000.toBigInteger(), 3_000.toBigInteger()),
).right()
coEvery { feeRepository.calculateFee(any(), any(), any()) } returns mockk()
coEvery { feeRepository.getEthereumFeeWithoutGas(any(), any()) } returns fee
val result = useCase(
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
transactionDataList = listOf(deployTx, approveTx, enterTx),
)
Truth.assertThat(result.isRight()).isTrue()
coVerify(ordering = Ordering.ORDERED) {
blockAidGasEstimate.getGasEstimation(any(), any())
feeRepository.getEthereumFeeWithoutGas(any(), any())
}
coVerify(inverse = true) {
feeRepository.calculateFee(any(), any(), deployTx)
feeRepository.calculateFee(any(), any(), approveTx)
feeRepository.calculateFee(any(), any(), enterTx)
}
val txs = (result as Either.Right).value
val deployFee = txs.first().fee as Fee.Ethereum.Legacy
val approveFee = txs[1].fee as Fee.Ethereum.Legacy
val enterFee = txs.last().fee as Fee.Ethereum.Legacy
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_000))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_000))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(3_000))
}
private fun getDeployTx() = uncompiled(