Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-15 20:15:37 +03:00
parent 22a32ec8f1
commit 99cdc003c8
16 changed files with 156 additions and 121 deletions

@ -1 +1 @@
Subproject commit 5ba0959d72f41e49a22f792e9a3d53e5e24e7713 Subproject commit 098662beb5b0123b11f5ee4873d4bd667ac93c73

View file

@ -116,9 +116,6 @@ class DefaultGaslessTransactionRepository(
} }
private companion object { private companion object {
const val TOKEN_RECEIVER_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000")
} }
} }

View file

@ -1,6 +1,7 @@
package com.tangem.domain.tokens package com.tangem.domain.tokens
import arrow.core.Either import arrow.core.Either
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.FeePaidCurrency
@ -16,9 +17,15 @@ class IsAmountSubtractAvailableUseCase(
suspend operator fun invoke( suspend operator fun invoke(
userWalletId: UserWalletId, userWalletId: UserWalletId,
currency: CryptoCurrency, currency: CryptoCurrency,
isGaslessEthTx: Boolean = false, maybeGaslessFee: Pair<CryptoCurrency.ID, Fee>? = null,
): Either<Throwable, Boolean> = Either.catch { ): Either<Throwable, Boolean> = Either.catch {
if (isGaslessEthTx) return@catch true val maybeTokenCurrency = currency as? CryptoCurrency.Token
if (maybeGaslessFee != null &&
maybeGaslessFee.second is Fee.Ethereum.TokenCurrency &&
maybeGaslessFee.first.contractAddress.equals(maybeTokenCurrency?.contractAddress, true)
) {
return@catch true
}
when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency.network)) { when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency.network)) {
is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin
is FeePaidCurrency.SameCurrency -> true is FeePaidCurrency.SameCurrency -> true

View file

@ -14,5 +14,6 @@ sealed class GetFeeError {
data object NetworkIsNotSupported : GaslessError() data object NetworkIsNotSupported : GaslessError()
data object NoSupportedTokensFound : GaslessError() data object NoSupportedTokensFound : GaslessError()
data object NotEnoughFunds : GaslessError() data object NotEnoughFunds : GaslessError()
data class DataError(val cause: Throwable?) : GaslessError()
} }
} }

View file

@ -89,7 +89,7 @@ class EstimateFeeForGaslessTxUseCase(
) )
}, },
catch = { catch = {
raise(GetFeeError.DataError(it)) raise(GaslessError.DataError(it))
}, },
) )
} }

View file

@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.Raise import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
@ -17,6 +18,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.transaction.error.mapToFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.transaction.raiseIllegalStateError
@ -45,62 +47,69 @@ class EstimateFeeForTokenUseCase(
amount: BigDecimal, amount: BigDecimal,
): Either<GetFeeError, TransactionFeeExtended> { ): Either<GetFeeError, TransactionFeeExtended> {
return either { return either {
val token = tokenCurrencyStatus.currency catch(
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { block = {
raise(GetFeeError.GaslessError.NetworkIsNotSupported) val token = tokenCurrencyStatus.currency
} if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) {
raise(GetFeeError.GaslessError.NetworkIsNotSupported)
}
val amountData = amount.convertToSdkAmount(tokenCurrencyStatus) val amountData = amount.convertToSdkAmount(tokenCurrencyStatus)
val result = if (userWallet is UserWallet.Cold && val result = if (userWallet is UserWallet.Cold &&
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
) { ) {
demoTransactionSender(userWallet, token).estimateFee( demoTransactionSender(userWallet, token).estimateFee(
amount = amountData, amount = amountData,
destination = "", destination = "",
) )
} else { } else {
walletManagersFacade.estimateFee( walletManagersFacade.estimateFee(
amount = amountData, amount = amountData,
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
network = token.network, network = token.network,
) )
} }
val initialTxFee = when (result) { val initialTxFee = when (result) {
is Result.Success -> result.data is Result.Success -> result.data
is Result.Failure -> raise(result.mapToFeeError()) is Result.Failure -> raise(result.mapToFeeError())
null -> raise(GetFeeError.UnknownError) null -> raise(GetFeeError.UnknownError)
} }
val initialFeeEth = initialTxFee.normal as? Fee.Ethereum val initialFeeEth = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError( ?: raiseIllegalStateError(
error = "only Fee.Ethereum supported, but was different", error = "only Fee.Ethereum supported, but was different",
) )
val nativeCurrency = currenciesRepository.getNetworkCoin( val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
networkId = token.network.id, networkId = token.network.id,
derivationPath = token.network.derivationPath, derivationPath = token.network.derivationPath,
)
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull()?.filter {
it.currency.network.id == token.network.id
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${token.network.id}")
val walletManager = prepareWalletManager(userWallet, token.network)
tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
).bind()
},
catch = {
raise(GaslessError.DataError(it))
},
) )
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull()?.filter {
it.currency.network.id == token.network.id
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${token.network.id}")
val walletManager = prepareWalletManager(userWallet, token.network)
tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
).bind()
} }
} }

View file

@ -53,7 +53,7 @@ class GetAvailableFeeTokensUseCase(
} }
}, },
catch = { catch = {
raise(GetFeeError.DataError(it)) raise(GetFeeError.GaslessError.DataError(it))
}, },
) )
} }

View file

@ -86,7 +86,7 @@ class GetFeeForGaslessUseCase(
) )
}, },
catch = { catch = {
raise(GetFeeError.DataError(it)) raise(GaslessError.DataError(it))
}, },
) )
} }

View file

@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.Raise import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
@ -15,6 +16,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
@ -40,50 +42,57 @@ class GetFeeForTokenUseCase(
transactionData: TransactionData, transactionData: TransactionData,
): Either<GetFeeError, TransactionFeeExtended> { ): Either<GetFeeError, TransactionFeeExtended> {
return either { return either {
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) { catch(
raise(GetFeeError.GaslessError.NetworkIsNotSupported) block = {
} if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) {
raise(GaslessError.NetworkIsNotSupported)
}
val walletManager = prepareWalletManager(userWallet, token.network) val walletManager = prepareWalletManager(userWallet, token.network)
val initialTxFee = tokenFeeCalculator.calculateInitialFee( val initialTxFee = tokenFeeCalculator.calculateInitialFee(
userWallet = userWallet, userWallet = userWallet,
network = token.network, network = token.network,
walletManager = walletManager, walletManager = walletManager,
transactionData = transactionData, transactionData = transactionData,
).bind() ).bind()
val initialFeeEth = initialTxFee.normal as? Fee.Ethereum val initialFeeEth = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError( ?: raiseIllegalStateError(
error = "only Fee.Ethereum supported, but was different", error = "only Fee.Ethereum supported, but was different",
) )
val nativeCurrency = currenciesRepository.getNetworkCoin( val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
networkId = token.network.id, networkId = token.network.id,
derivationPath = token.network.derivationPath, derivationPath = token.network.derivationPath,
)
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull()?.filter {
it.currency.network.id == token.network.id
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${token.network.id}")
val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == token.id
} ?: raiseIllegalStateError("token currency not found for network ${token.network.id}")
tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
).bind()
},
catch = {
raise(GaslessError.DataError(it))
},
) )
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull()?.filter {
it.currency.network.id == token.network.id
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${token.network.id}")
val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == token.id
} ?: raiseIllegalStateError("token currency not found for network ${token.network.id}")
tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
).bind()
} }
} }

View file

@ -26,6 +26,7 @@ import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode import java.math.RoundingMode
internal class TokenFeeCalculator( internal class TokenFeeCalculator(
@ -51,7 +52,7 @@ internal class TokenFeeCalculator(
val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) { val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) {
is Result.Success -> result.data is Result.Success -> result.data
is Result.Failure -> raise(result.mapToFeeError()) is Result.Failure -> raise(GaslessError.DataError(result.error))
} }
maybeFee maybeFee
} }
@ -115,7 +116,7 @@ internal class TokenFeeCalculator(
val feeTransferGasLimit = when (feeTransferGasLimitResult) { val feeTransferGasLimit = when (feeTransferGasLimitResult) {
is Result.Failure -> raise(GetFeeError.DataError(feeTransferGasLimitResult.error)) is Result.Failure -> raise(GetFeeError.DataError(feeTransferGasLimitResult.error))
is Result.Success -> feeTransferGasLimitResult.data is Result.Success -> feeTransferGasLimitResult.data
} }.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
@ -197,6 +198,7 @@ internal class TokenFeeCalculator(
/** Gas price safety multiplier for fee calculation */ /** Gas price safety multiplier for fee calculation */
const val GAS_PRICE_MULTIPLIER = 2 const val GAS_PRICE_MULTIPLIER = 2
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
/** /**
* Increases BigDecimal value by specified percentage. * Increases BigDecimal value by specified percentage.
@ -216,5 +218,11 @@ internal class TokenFeeCalculator(
val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100"))) val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100")))
return this.multiply(multiplier) return this.multiply(multiplier)
} }
private fun BigInteger.increaseByPercent(percent: Int): BigInteger {
require(percent >= 0) { "Percent must be non-negative" }
val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100")))
return BigDecimal(this).multiply(multiplier).toBigInteger()
}
} }
} }

View file

@ -110,7 +110,7 @@ class TokenFeeCalculatorTest {
// Then // Then
assertTrue(result.isLeft()) assertTrue(result.isLeft())
result.onLeft { error -> result.onLeft { error ->
assertTrue(error is GetFeeError.DataError) assertTrue(error is GetFeeError.GaslessError)
} }
} }
@ -363,7 +363,7 @@ class TokenFeeCalculatorTest {
// Expected // Expected
val expectedAmount = Amount( val expectedAmount = Amount(
value = BigDecimal("36.200000000000000000000000000000000000"), value = BigDecimal("37.400000000000000000000000000000000000"),
token = Token( token = Token(
name = "USDC", name = "USDC",
symbol = "USDC", symbol = "USDC",
@ -371,9 +371,9 @@ class TokenFeeCalculatorTest {
decimals = 6, decimals = 6,
) )
) )
val expectedGasLimit = "181000".toBigInteger() val expectedGasLimit = "187000".toBigInteger()
val expectedCoinPriceInToken = BigInteger("2020000000") // 2000 * 1.01 * 10^6 val expectedCoinPriceInToken = BigInteger("2020000000") // 2000 * 1.01 * 10^6
val expectedFeeTransferLimit = "60000".toBigInteger() val expectedFeeTransferLimit = "66000".toBigInteger()
val expectedBaseGas = "21000".toBigInteger() val expectedBaseGas = "21000".toBigInteger()
// Given // Given

View file

@ -628,14 +628,14 @@ internal class SendConfirmModel @Inject constructor(
private fun updateAmountSubtractAvailability() { private fun updateAmountSubtractAvailability() {
modelScope.launch { modelScope.launch {
val isGaslessEthTx = val fee = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal
feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency // we assume if feeExtraInfo is empty then pay fee in the main currency
isAmountSubtractAvailable = val feeTokenId = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.feeTokenId ?: cryptoCurrency.id
isAmountSubtractAvailableUseCase( isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
currency = cryptoCurrency, currency = cryptoCurrency,
isGaslessEthTx = isGaslessEthTx, maybeGaslessFee = fee?.let { feeTokenId to fee },
).getOrElse { false } ).getOrElse { false }
} }
} }

View file

@ -106,10 +106,12 @@ internal class NotificationsModel @Inject constructor(
} }
private suspend fun checkIfSubtractAvailable() { private suspend fun checkIfSubtractAvailable() {
val feeCurrencyId = notificationData.feeCryptoCurrencyStatus.currency.id
val fee = notificationData.fee
isAmountSubtractAvailable = isAmountSubtractAvailableUseCase( isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(
userWalletId = userWalletId, userWalletId = userWalletId,
currency = currency, currency = currency,
isGaslessEthTx = notificationData.fee is Fee.Ethereum.TokenCurrency, maybeGaslessFee = fee?.let { feeCurrencyId to fee },
).getOrElse { false } ).getOrElse { false }
} }

View file

@ -1439,8 +1439,10 @@ internal class StakingModel @Inject constructor(
} }
private suspend fun checkIfSubtractAvailable() { private suspend fun checkIfSubtractAvailable() {
isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, cryptoCurrencyStatus.currency) isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(
.getOrElse { false } userWalletId = userWalletId,
currency = cryptoCurrencyStatus.currency,
).getOrElse { false }
} }
private fun isTopHeatupCase(): Boolean { private fun isTopHeatupCase(): Boolean {

View file

@ -3,7 +3,6 @@ package com.tangem.features.swap.v2.impl.sendviaswap.confirm.model
import arrow.core.Either import arrow.core.Either
import arrow.core.getOrElse import arrow.core.getOrElse
import arrow.core.left import arrow.core.left
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
@ -349,13 +348,14 @@ internal class SendWithSwapConfirmModel @Inject constructor(
private fun updateAmountSubtractAvailability() { private fun updateAmountSubtractAvailability() {
modelScope.launch { modelScope.launch {
val isGaslessEthTx = val fee = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal
feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency val feeTokenId =
feeUMV2?.feeExtraInfo?.transactionFeeExtended?.feeTokenId ?: primaryCurrencyStatus.currency.id
isAmountSubtractAvailable = isAmountSubtractAvailable =
isAmountSubtractAvailableUseCase( isAmountSubtractAvailableUseCase(
userWalletId = params.userWallet.walletId, userWalletId = params.userWallet.walletId,
currency = primaryCurrencyStatus.currency, currency = primaryCurrencyStatus.currency,
isGaslessEthTx = isGaslessEthTx, maybeGaslessFee = fee?.let { feeTokenId to fee },
).getOrElse { false } ).getOrElse { false }
} }
} }

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico # https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1364" tangemBlockchainSdk = "develop-1365"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-573" tangemCardSdk = "develop-573"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^