Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-02 14:11:30 +03:00
commit 45156f05b0
1184 changed files with 47887 additions and 11982 deletions

View file

@ -36,4 +36,9 @@ dependencies {
implementation(projects.domain.demo)
implementation(projects.domain.card)
api(projects.domain.networks)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
}

View file

@ -9,4 +9,14 @@ sealed class GetFeeError {
data object KaspaZeroUtxo : BlockchainErrors()
data object SuiOneCoinRequired : BlockchainErrors()
}
/**
* Gasless transaction related errors, model logic uses this errors types, don't remove or change them
*/
sealed class GaslessError : GetFeeError() {
data object NetworkIsNotSupported : GaslessError()
data object NoSupportedTokensFound : GaslessError()
data object NotEnoughFunds : GaslessError()
data class DataError(val cause: Throwable?) : GaslessError()
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.transaction
import arrow.core.raise.Raise
import com.tangem.domain.transaction.error.GetFeeError
fun Raise<GetFeeError>.raiseIllegalStateError(error: String): Nothing {
raise(GetFeeError.DataError(IllegalStateException(error)))
}

View file

@ -0,0 +1,68 @@
package com.tangem.domain.transaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData
import java.math.BigInteger
interface GaslessTransactionRepository {
suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency>
suspend fun getTokenFeeReceiverAddress(): String
/**
* Sends gasless transaction to the gasless service for signing and returns the signed result.
*
* Flow:
* 1. User signs the gasless transaction data locally with their private key
* 2. This method submits the transaction + user signature to the gasless service
* 3. Service validates the signature and transaction data
* 4. Service adds its own signature for fee delegation (pays gas in tokens)
* 5. Service constructs the final EIP-1559 transaction with all signatures
* 6. Service returns the fully signed transaction ready for broadcasting
*
* The gasless service acts as a relayer that:
* - Pays network gas fees on behalf of the user
* - Receives payment in the specified token from the user's wallet
* - Ensures atomic execution (either both transfers succeed or both fail)
*
* @param gaslessTransactionData domain model containing:
* - transaction: target contract call data (to, value, data)
* - fee: token payment configuration (feeToken, maxTokenFee, etc.)
* - nonce: user's contract nonce to prevent replay attacks
* @param signature user's ECDSA signature of the gasless transaction in hex format (0x...)
* Signs keccak256 hash of the transaction data
* @param userAddress user's Ethereum address (EOA or contract wallet)
* @param network blockchain network (Ethereum, Polygon, BSC, etc.)
* Used to determine chainId for the request
* @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract
* Required only when user's EOA needs to temporarily act as contract wallet
* Contains signature authorizing delegation to entry point contract
* @return [GaslessSignedTransactionResult] containing:
* - signedTransaction: complete RLP-encoded transaction ready to broadcast
* - gasLimit: actual gas limit allocated by the service
* - maxFeePerGas: maximum fee per gas (base + priority) in wei
* - maxPriorityFeePerGas: tip for validators in wei (EIP-1559)
* @throws IllegalStateException if network is not supported or chainId cannot be determined
* @throws Exception if service returns error or network request fails
*/
suspend fun signGaslessTransaction(
gaslessTransactionData: GaslessTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult
/**
* Hardcoded value as baseGas
*/
fun getBaseGasForTransaction(): BigInteger
fun getChainIdForNetwork(network: Network): Int
suspend fun getGaslessFeeAddresses(): Set<String>
}

View file

@ -0,0 +1,36 @@
package com.tangem.domain.transaction.models
import java.math.BigInteger
/**
* EIP-7702 authorization for account abstraction.
* Used for delegating EOA (Externally Owned Account) control to a smart contract.
*
* EIP-7702 allows EOAs to temporarily act as smart contract wallets by delegating
* their authority to a contract address for a specific transaction.
*
* @property chainId blockchain network chain ID
* @property address contract address to delegate authority to (entry point contract)
* @property nonce authorization nonce to prevent replay attacks
* @property yParity recovery ID for signature (0 or 1)
* @property r ECDSA signature component R
* @property s ECDSA signature component S
*
* @see <a href="https://eips.ethereum.org/EIPS/eip-7702">EIP-7702 Specification</a>
*/
data class Eip7702Authorization(
val chainId: Int,
val address: String,
val nonce: BigInteger,
val yParity: Int,
val r: String,
val s: String,
) {
init {
require(chainId > 0) { "Chain ID must be positive" }
require(address.isNotBlank()) { "Address must not be blank" }
require(yParity in 0..1) { "yParity must be 0 or 1" }
require(r.isNotBlank()) { "Signature component R must not be blank" }
require(s.isNotBlank()) { "Signature component S must not be blank" }
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.transaction.models
/**
* Result of gasless transaction signing from the gasless service.
* Contains the fully signed transaction ready for broadcasting and gas parameters.
*
* After the user signs the gasless transaction data locally, the service:
* 1. Validates the signature
* 2. Adds its own signature for fee delegation
* 3. Constructs the final transaction
* 4. Returns this signed transaction with gas parameters
*
* @property txHash sent tx hash
*/
data class GaslessSignedTransactionResult(
val txHash: String,
) {
init {
require(txHash.isNotBlank()) { "Tx hash must not be blank" }
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.domain.transaction.models
import java.math.BigInteger
/**
* Domain model for gasless transaction.
* Represents complete transaction data with fee delegation metadata.
*/
data class GaslessTransactionData(
/** Transaction details */
val transaction: Transaction,
/** Fee payment configuration */
val fee: Fee,
/** Nonce from user's contract */
val nonce: BigInteger,
) {
/**
* Core transaction data.
*
* @property to destination address
* @property value transaction value in wei (currently always 0 for gasless)
* @property data encoded transaction data (contract call)
*/
data class Transaction(
val to: String,
val value: BigInteger,
val data: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Transaction
if (to != other.to) return false
if (value != other.value) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = to.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
/**
* Fee configuration for gasless transaction.
*
* @property feeToken token address used for fee payment
* @property maxTokenFee maximum fee amount in fee token units
* @property coinPriceInToken price of native coin in fee token units
* @property feeTransferGasLimit gas limit for fee token transfer
* @property baseGas base gas cost (currently constant BASE_GAS, may vary in future)
* @property feeReceiver address receiving the fee payment
*/
data class Fee(
val feeToken: String,
val maxTokenFee: BigInteger,
val coinPriceInToken: BigInteger,
val feeTransferGasLimit: BigInteger,
val baseGas: BigInteger,
val feeReceiver: String,
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.domain.transaction.models
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrency
data class TransactionFeeExtended(
val transactionFee: TransactionFee,
val feeTokenId: CryptoCurrency.ID,
)

View file

@ -0,0 +1,361 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider
import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.formatHex
import com.tangem.blockchain.extensions.normalizeByteArray
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.common.extensions.toHexString
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigInteger
class CreateAndSendGaslessTransactionUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
) {
suspend operator fun invoke(
userWallet: UserWallet,
transactionData: TransactionData,
fee: TransactionFeeExtended,
): Either<SendTransactionError, String> = either {
catch(
block = {
val uncompiledTxData = validateTransactionData(transactionData)
val context = prepareGaslessContext(userWallet, uncompiledTxData, fee)
val signedData = signGaslessTransactionByUser(userWallet, context, uncompiledTxData)
signAndSendTransactionOnBackend(context, signedData, uncompiledTxData)
},
catch = {
raise(SendTransactionError.DataError(it.message))
},
)
}
/**
* Validates and casts transaction data to Uncompiled type.
*/
private fun validateTransactionData(transactionData: TransactionData): TransactionData.Uncompiled {
return transactionData as? TransactionData.Uncompiled
?: error("Uncompiled transaction data required")
}
/**
* Prepares all necessary context for gasless transaction.
* Includes: wallet manager, gasless provider, token status, nonce, transaction data.
*/
private suspend fun prepareGaslessContext(
userWallet: UserWallet,
transactionData: TransactionData.Uncompiled,
fee: TransactionFeeExtended,
): GaslessContext {
val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
fee.feeTokenId,
).getOrNull() ?: error("Token for fee not found")
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWallet.walletId,
tokenForFeeStatus.currency.network,
) ?: error("WalletManager not found for network ${tokenForFeeStatus.currency.network.id}")
val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error(
"WalletManager for network ${tokenForFeeStatus.currency.network.id} " +
"does not support gasless transactions",
)
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
val gaslessTransactionData = createGaslessTransactionData(
transactionData = transactionData,
txFee = fee,
tokenFeeStatus = tokenForFeeStatus,
nonce = gaslessContractNonce,
)
val chainId = gaslessTransactionRepository.getChainIdForNetwork(tokenForFeeStatus.currency.network)
return GaslessContext(
walletManager = walletManager,
gaslessDataProvider = gaslessDataProvider,
tokenForFeeStatus = tokenForFeeStatus,
gaslessTransactionData = gaslessTransactionData,
chainId = chainId,
)
}
/**
* Gets contract nonce with fallback to zero on failure.
*/
private suspend fun getContractNonce(
gaslessDataProvider: EthereumGaslessDataProvider,
userAddress: String,
): BigInteger {
return when (val nonceResult = gaslessDataProvider.getGaslessContractNonce(userAddress)) {
is Result.Failure -> BigInteger.ZERO
is Result.Success -> nonceResult.data
}
}
/**
* Signs gasless transaction and EIP-7702 authorization.
* Returns prepared signatures and authorization data.
*/
private suspend fun signGaslessTransactionByUser(
userWallet: UserWallet,
context: GaslessContext,
transactionData: TransactionData.Uncompiled,
): SignedGaslessData {
val eip712Data = Eip712TypedDataBuilder.build(
gaslessTransaction = context.gaslessTransactionData,
chainId = context.chainId,
verifyingContract = transactionData.sourceAddress,
)
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider)
val signedHashes = signHashes(
userWallet = userWallet,
walletManager = context.walletManager,
hashesToOperate = HashesToOperate(
eip712Hash = eip712HashToSign,
eip7702Hash = eip7702Data.data,
),
)
val decompressedPublicKey = context.walletManager
.wallet.publicKey.blockchainKey
.toDecompressedPublicKey()
val preparedEip712Hash = UnmarshalHelper.unmarshalSignatureExtended(
signature = signedHashes.eip712Hash,
hash = eip712HashToSign,
publicKey = decompressedPublicKey,
).asRSVLegacyEVM().toHexString().formatHex().lowercase()
val extendedEip7702Data = UnmarshalHelper.unmarshalSignatureExtended(
signature = signedHashes.eip7702Hash,
hash = eip7702Data.data,
publicKey = decompressedPublicKey,
)
val eip7702Auth = Eip7702Authorization(
chainId = context.chainId,
address = eip7702Data.executorAddress,
nonce = eip7702Data.nonce,
yParity = extendedEip7702Data.recId,
r = extendedEip7702Data.r.toFormattedHex(bytes = 32),
s = extendedEip7702Data.s.toFormattedHex(bytes = 32),
)
return SignedGaslessData(
eip712Signature = preparedEip712Hash,
eip7702Auth = eip7702Auth,
)
}
/**
* Sends gasless transaction to the service.
*/
private suspend fun signAndSendTransactionOnBackend(
context: GaslessContext,
signedData: SignedGaslessData,
transactionData: TransactionData.Uncompiled,
): String {
val txHash = gaslessTransactionRepository.signGaslessTransaction(
network = context.tokenForFeeStatus.currency.network,
gaslessTransactionData = context.gaslessTransactionData,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
transactionData = transactionData,
txHash = txHash,
contractAddress = transactionData.contractAddress,
)
return txHash
}
private suspend fun signHashes(
userWallet: UserWallet,
walletManager: WalletManager,
hashesToOperate: HashesToOperate,
): HashesToOperate {
val signer = getSigner(userWallet)
val hashesToSign = listOf(hashesToOperate.eip712Hash, hashesToOperate.eip7702Hash)
return when (val signerResult = signer.sign(hashesToSign, walletManager.wallet.publicKey)) {
is CompletionResult.Failure -> error("Signing failed: ${signerResult.error.message ?: "sdk error"}")
is CompletionResult.Success -> {
val signatures = signerResult.data
require(signatures.size == 2) { "Expected 2 signatures, got ${signatures.size}" }
HashesToOperate(
eip712Hash = signatures[0],
eip7702Hash = signatures[1],
)
}
}
}
private fun getSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) {
is UserWallet.Cold -> {
val card = userWallet.scanResponse.card
val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins
cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
)
}
is UserWallet.Hot -> getHotWalletSigner(userWallet)
}
}
private suspend fun createGaslessTransactionData(
transactionData: TransactionData.Uncompiled,
txFee: TransactionFeeExtended,
tokenFeeStatus: CryptoCurrencyStatus,
nonce: BigInteger,
): GaslessTransactionData {
val transaction = buildTransaction(transactionData)
val fee = buildFee(txFee, tokenFeeStatus)
return GaslessTransactionData(
transaction = transaction,
fee = fee,
nonce = nonce,
)
}
private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction {
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
?: error("Ethereum call data is required")
// Native amount is always zero in gasless transactions for now
// we don't support gasless transfers of native currency
val nativeAmount = BigInteger.ZERO
return GaslessTransactionData.Transaction(
to = getDestinationAddress(transactionData),
value = nativeAmount,
data = callData.data,
)
}
private suspend fun buildFee(
txFee: TransactionFeeExtended,
tokenFeeStatus: CryptoCurrencyStatus,
): GaslessTransactionData.Fee {
val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token
?: error("Fee currency must be a token")
val feeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency
?: error("Fee must be in token currency")
return GaslessTransactionData.Fee(
feeToken = tokenForFee.contractAddress,
maxTokenFee = feeInTokenCurrency.gasLimit,
coinPriceInToken = feeInTokenCurrency.coinPriceInToken,
feeTransferGasLimit = feeInTokenCurrency.feeTransferGasLimit,
baseGas = feeInTokenCurrency.baseGas,
feeReceiver = gaslessTransactionRepository.getTokenFeeReceiverAddress(),
)
}
private suspend fun getEIP7702DataForGasless(
gaslessDataProvider: EthereumGaslessDataProvider,
): EIP7702AuthorizationData {
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData()) {
is Result.Failure -> throw dataResult.error
is Result.Success -> dataResult.data
}
}
private fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
val contractAddress = txData.contractAddress
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
ethereumCallData.destinationAddress
} else {
contractAddress ?: error("supports only Token transaction with contract address")
}
}
/**
* Context containing all prepared data for gasless transaction.
*/
private data class GaslessContext(
val walletManager: WalletManager,
val gaslessDataProvider: EthereumGaslessDataProvider,
val tokenForFeeStatus: CryptoCurrencyStatus,
val gaslessTransactionData: GaslessTransactionData,
val chainId: Int,
)
/**
* Signed data ready for submission.
*/
private data class SignedGaslessData(
val eip712Signature: String,
val eip7702Auth: Eip7702Authorization,
)
private data class HashesToOperate(
val eip712Hash: ByteArray,
val eip7702Hash: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HashesToOperate
if (!eip712Hash.contentEquals(other.eip712Hash)) return false
if (!eip7702Hash.contentEquals(other.eip7702Hash)) return false
return true
}
override fun hashCode(): Int {
var result = eip712Hash.contentHashCode()
result = 31 * result + eip7702Hash.contentHashCode()
return result
}
}
private companion object {
fun BigInteger.toFormattedHex(bytes: Int): String {
return toByteArray().normalizeByteArray(bytes).toHexString().formatHex()
}
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.domain.transaction.usecase.gasless
import com.tangem.common.extensions.toHexString
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONArray
import org.json.JSONObject
/**
* Builder for creating EIP-712 typed data JSON for gasless transaction signing.
*
* The EIP-712 standard allows users to sign typed, structured data instead of raw bytes.
* This provides better UX as wallets can show users exactly what they're signing.
*
* Example usage:
* ```kotlin
* val typedDataJson = Eip712TypedDataBuilder.build(
* gaslessTransaction = gaslessTransactionData,
* chainId = 1,
* verifyingContract = "0x1234..."
* )
* val signature = wallet.signTypedData(typedDataJson)
* ```
*/
object Eip712TypedDataBuilder {
private const val DOMAIN_NAME = "Tangem7702GaslessExecutor"
private const val DOMAIN_VERSION = "1"
private const val PRIMARY_TYPE = "GaslessTransaction"
/**
* Builds EIP-712 typed data JSON for gasless transaction.
*
* @param gaslessTransaction domain model with transaction and fee data
* @param chainId blockchain network chain ID
* @param verifyingContract address of the deployed gasless executor contract
* @return JSON string ready for EIP-712 signing
*/
fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String {
val typedData = JSONObject().apply {
put("types", buildTypes())
put("primaryType", PRIMARY_TYPE)
put("domain", buildDomain(chainId, verifyingContract))
put("message", buildMessage(gaslessTransaction))
}
return typedData.toString()
}
/**
* Builds the type definitions for all structures.
* This schema is fixed and defines the structure of the data being signed.
*/
@Suppress("NestedScopeFunctions")
private fun buildTypes(): JSONObject {
return JSONObject().apply {
put("EIP712Domain", JSONArray().apply {
put(typeProperty("name", "string"))
put(typeProperty("version", "string"))
put(typeProperty("chainId", "uint256"))
put(typeProperty("verifyingContract", "address"))
})
put("Transaction", JSONArray().apply {
put(typeProperty("to", "address"))
put(typeProperty("value", "uint256"))
put(typeProperty("data", "bytes"))
})
put("Fee", JSONArray().apply {
put(typeProperty("feeToken", "address"))
put(typeProperty("maxTokenFee", "uint256"))
put(typeProperty("coinPriceInToken", "uint256"))
put(typeProperty("feeTransferGasLimit", "uint256"))
put(typeProperty("baseGas", "uint256"))
put(typeProperty("feeReceiver", "address"))
})
put("GaslessTransaction", JSONArray().apply {
put(typeProperty("transaction", "Transaction"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
})
}
}
/**
* Creates a type property JSON object.
*/
private fun typeProperty(name: String, type: String): JSONObject {
return JSONObject().apply {
put("name", name)
put("type", type)
}
}
/**
* Builds the domain separator.
*/
private fun buildDomain(chainId: Int, verifyingContract: String): JSONObject {
return JSONObject().apply {
put("name", DOMAIN_NAME)
put("version", DOMAIN_VERSION)
put("chainId", chainId)
put("verifyingContract", verifyingContract)
}
}
/**
* Builds the message data from gasless transaction.
*/
@Suppress("NestedScopeFunctions")
private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject {
return JSONObject().apply {
put("transaction", JSONObject().apply {
put("to", gaslessTransaction.transaction.to)
put("value", gaslessTransaction.transaction.value.toString())
put("data", gaslessTransaction.transaction.data.toHexString())
})
put("fee", JSONObject().apply {
put("feeToken", gaslessTransaction.fee.feeToken)
put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString())
put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString())
put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString())
put("baseGas", gaslessTransaction.fee.baseGas.toString())
put("feeReceiver", gaslessTransaction.fee.feeReceiver)
})
put("nonce", gaslessTransaction.nonce.toString())
}
}
}

View file

@ -0,0 +1,186 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
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.raiseIllegalStateError
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
@Suppress("LongParameterList")
class EstimateFeeForGaslessTxUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val currenciesRepository: CurrenciesRepository,
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
private val estimateFeeUseCase: EstimateFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
)
suspend operator fun invoke(
userWallet: UserWallet,
amount: BigDecimal,
sendingTokenCurrencyStatus: CryptoCurrencyStatus,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
catch(
block = {
val network = sendingTokenCurrencyStatus.currency.network
val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = network.id,
derivationPath = network.derivationPath,
)
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) {
estimateFeeUseCase.invoke(
userWallet = userWallet,
amount = amount,
cryptoCurrencyStatus = sendingTokenCurrencyStatus,
).fold(
ifLeft = { raise(it) },
ifRight = { fee ->
return@either TransactionFeeExtended(
transactionFee = fee,
feeTokenId = nativeCurrency.id,
)
},
)
}
val walletManager = prepareWalletManager(userWallet, network)
val initialFee = tokenFeeCalculator.estimateInitialFee(
userWallet = userWallet,
amount = amount,
txTokenCurrencyStatus = sendingTokenCurrencyStatus,
).bind()
selectFeePaymentStrategy(
userWallet = userWallet,
walletManager = walletManager,
nativeCurrency = nativeCurrency,
network = network,
initialFee = initialFee,
)
},
catch = {
raise(GaslessError.DataError(it))
},
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.prepareWalletManager(
userWallet: UserWallet,
network: Network,
): EthereumWalletManager {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWallet.walletId,
network = network,
)
val ethereumWalletManager = walletManager as? EthereumWalletManager
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
return ethereumWalletManager
}
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
userWallet: UserWallet,
walletManager: EthereumWalletManager,
nativeCurrency: CryptoCurrency,
network: Network,
initialFee: TransactionFee,
): TransactionFeeExtended {
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val networkCurrenciesStatuses = userCurrenciesStatuses.filter {
it.currency.network.id == network.id
}
val nativeCurrencyStatus = networkCurrenciesStatuses.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${network.id}")
val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO
return if (nativeBalance >= feeValue) {
TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id)
} else {
findTokensToPayFee(
walletManager = walletManager,
initialTxFee = initialFee,
nativeCurrencyStatus = nativeCurrencyStatus,
networkCurrenciesStatuses = networkCurrenciesStatuses,
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.findTokensToPayFee(
walletManager: EthereumWalletManager,
initialTxFee: TransactionFee,
nativeCurrencyStatus: CryptoCurrencyStatus,
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
): TransactionFeeExtended {
val initialFee = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}",
)
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress
}.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount }
.filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
token.contractAddress.lowercase() in supportedGaslessTokens
}
/**
* Selects token with highest balance to maximize chances of successful fee payment.
* Returns null if no suitable tokens found.
*/
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
?: raise(GaslessError.NoSupportedTokensFound)
return tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenForPayFeeStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFee,
).bind()
}
}

View file

@ -0,0 +1,109 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
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.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
class EstimateFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val currenciesRepository: CurrenciesRepository,
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
)
suspend operator fun invoke(
userWallet: UserWallet,
feeTokenCurrencyStatus: CryptoCurrencyStatus,
sendingTokenCurrencyStatus: CryptoCurrencyStatus,
amount: BigDecimal,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
catch(
block = {
val token = feeTokenCurrencyStatus.currency
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) {
raise(GaslessError.NetworkIsNotSupported)
}
val initialTxFee = tokenFeeCalculator.estimateInitialFee(
userWallet = userWallet,
amount = amount,
txTokenCurrencyStatus = sendingTokenCurrencyStatus,
).bind()
val initialFeeEth = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
error = "only Fee.Ethereum supported, but was different",
)
val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = token.network.id,
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 = feeTokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
).bind()
},
catch = {
raise(GaslessError.DataError(it))
},
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.prepareWalletManager(
userWallet: UserWallet,
network: Network,
): EthereumWalletManager {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWallet.walletId,
network = network,
)
val ethereumWalletManager = walletManager as? EthereumWalletManager
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
return ethereumWalletManager
}
}

View file

@ -0,0 +1,94 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.raiseIllegalStateError
class GetAvailableFeeTokensUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val currenciesRepository: CurrenciesRepository,
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
) {
/**
* Retrieves available tokens for gasless fee payment.
*
* @return List where first element is always native currency (for fallback)
*/
suspend operator fun invoke(
userWallet: UserWallet,
network: Network,
): Either<GetFeeError, List<CryptoCurrencyStatus>> {
return either {
catch(
block = {
val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull()
?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val nativeCurrencyStatus = getNativeCurrencyStatus(userWallet, network, userCurrenciesStatuses)
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) {
return@either listOf(nativeCurrencyStatus)
}
val gaslessTokens = getGaslessTokens(network, userCurrenciesStatuses)
buildList {
add(nativeCurrencyStatus)
addAll(gaslessTokens)
}
},
catch = {
raise(GetFeeError.GaslessError.DataError(it))
},
)
}
}
private suspend fun Raise<GetFeeError>.getNativeCurrencyStatus(
userWallet: UserWallet,
network: Network,
userCurrenciesStatuses: List<CryptoCurrencyStatus>,
): CryptoCurrencyStatus {
val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = network.id,
derivationPath = network.derivationPath,
)
return userCurrenciesStatuses.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("no native currency found")
}
private suspend fun getGaslessTokens(
network: Network,
userCurrenciesStatuses: List<CryptoCurrencyStatus>,
): List<CryptoCurrencyStatus> {
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(network)
.mapNotNull {
(it as? CryptoCurrency.Token)?.contractAddress?.lowercase()
}.toSet()
return userCurrenciesStatuses
.asSequence()
.filter { it.value.yieldSupplyStatus == null }
.filter { it.currency.network.id == network.id }
.filter { currencyStatus ->
val token = currencyStatus.currency
token is CryptoCurrency.Token && supportedGaslessTokens.contains(token.contractAddress.lowercase())
}
.toList()
}
}

View file

@ -0,0 +1,183 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
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.raiseIllegalStateError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
@Suppress("LongParameterList")
class GetFeeForGaslessUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val currenciesRepository: CurrenciesRepository,
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
)
suspend operator fun invoke(
userWallet: UserWallet,
network: Network,
transactionData: TransactionData,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
catch(
block = {
val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = network.id,
derivationPath = network.derivationPath,
)
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) {
getFeeUseCase.invoke(userWallet, network, transactionData).fold(
ifLeft = { raise(it) },
ifRight = { fee ->
return@either TransactionFeeExtended(
transactionFee = fee,
feeTokenId = nativeCurrency.id,
)
},
)
}
val walletManager = prepareWalletManager(userWallet, network)
val initialFee = tokenFeeCalculator.calculateInitialFee(
userWallet = userWallet,
network = network,
walletManager = walletManager,
transactionData = transactionData,
).bind()
selectFeePaymentStrategy(
userWallet = userWallet,
walletManager = walletManager,
nativeCurrency = nativeCurrency,
network = network,
initialFee = initialFee,
)
},
catch = {
raise(GaslessError.DataError(it))
},
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.prepareWalletManager(
userWallet: UserWallet,
network: Network,
): EthereumWalletManager {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWallet.walletId,
network = network,
)
val ethereumWalletManager = walletManager as? EthereumWalletManager
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
return ethereumWalletManager
}
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
userWallet: UserWallet,
walletManager: EthereumWalletManager,
nativeCurrency: CryptoCurrency,
network: Network,
initialFee: TransactionFee,
): TransactionFeeExtended {
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val networkCurrenciesStatuses = userCurrenciesStatuses.filter {
it.currency.network.id == network.id
}
val nativeCurrencyStatus = networkCurrenciesStatuses.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${network.id}")
val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO
return if (nativeBalance >= feeValue) {
TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id)
} else {
findTokensToPayFee(
walletManager = walletManager,
initialTxFee = initialFee,
nativeCurrencyStatus = nativeCurrencyStatus,
networkCurrenciesStatuses = networkCurrenciesStatuses,
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.findTokensToPayFee(
walletManager: EthereumWalletManager,
initialTxFee: TransactionFee,
nativeCurrencyStatus: CryptoCurrencyStatus,
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
): TransactionFeeExtended {
val initialFee = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}",
)
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress
}.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount }
.filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
token.contractAddress.lowercase() in supportedGaslessTokens
}
/**
* Selects token with highest balance to maximize chances of successful fee payment.
* Returns null if no suitable tokens found.
*/
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
?: raise(GaslessError.NoSupportedTokensFound)
return tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenForPayFeeStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFee,
).bind()
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
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.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
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.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade
class GetFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val currenciesRepository: CurrenciesRepository,
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
)
suspend operator fun invoke(
userWallet: UserWallet,
token: CryptoCurrency,
transactionData: TransactionData,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
catch(
block = {
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) {
raise(GaslessError.NetworkIsNotSupported)
}
val walletManager = prepareWalletManager(userWallet, token.network)
val initialTxFee = tokenFeeCalculator.calculateInitialFee(
userWallet = userWallet,
network = token.network,
walletManager = walletManager,
transactionData = transactionData,
).bind()
val initialFeeEth = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
error = "only Fee.Ethereum supported, but was different",
)
val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = token.network.id,
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))
},
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.prepareWalletManager(
userWallet: UserWallet,
network: Network,
): EthereumWalletManager {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWallet.walletId,
network = network,
)
val ethereumWalletManager = walletManager as? EthereumWalletManager
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
return ethereumWalletManager
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.transaction.usecase.gasless
import com.tangem.domain.models.network.Network
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
/**
* Use case to check if gasless fee is supported for a given network.
*/
class IsGaslessFeeSupportedForNetwork(
private val currencyChecksRepository: CurrencyChecksRepository,
) {
operator fun invoke(network: Network): Boolean {
return currencyChecksRepository.isNetworkSupportedForGaslessTx(network)
}
}

View file

@ -0,0 +1,234 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
import com.tangem.blockchain.common.Amount
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.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.GaslessTransactionRepository
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.raiseIllegalStateError
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
internal class TokenFeeCalculator(
private val walletManagersFacade: WalletManagersFacade,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val demoConfig: DemoConfig,
) {
suspend fun calculateInitialFee(
userWallet: UserWallet,
network: Network,
walletManager: EthereumWalletManager,
transactionData: TransactionData,
): Either<GetFeeError, TransactionFee> {
return either {
val transactionSender = if (userWallet is UserWallet.Cold &&
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
) {
demoTransactionSender(userWallet, network)
} else {
walletManager
}
val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) {
is Result.Success -> result.data
is Result.Failure -> raise(GaslessError.DataError(result.error))
}
maybeFee
}
}
suspend fun estimateInitialFee(
userWallet: UserWallet,
amount: BigDecimal,
txTokenCurrencyStatus: CryptoCurrencyStatus,
): Either<GetFeeError, TransactionFee> {
return either {
val network = txTokenCurrencyStatus.currency.network
val amountData = amount.convertToSdkAmount(txTokenCurrencyStatus)
val result = if (userWallet is UserWallet.Cold &&
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
) {
demoTransactionSender(userWallet, network).estimateFee(
amount = amountData,
destination = "",
)
} else {
walletManagersFacade.estimateFee(
amount = amountData,
userWalletId = userWallet.walletId,
network = network,
)
}
when (result) {
is Result.Success -> result.data
is Result.Failure -> raise(GaslessError.DataError(result.error))
null -> raise(GetFeeError.UnknownError)
}
}
}
suspend fun calculateTokenFee(
walletManager: EthereumWalletManager,
tokenForPayFeeStatus: CryptoCurrencyStatus,
nativeCurrencyStatus: CryptoCurrencyStatus,
initialFee: Fee.Ethereum,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
// fast finish to skip calculations if no funds in token
if (tokenForPayFeeStatus.value.amount?.isZero() == true) {
raise(GaslessError.NotEnoughFunds)
}
val tokenForPayFee = tokenForPayFeeStatus.currency as? CryptoCurrency.Token
?: raiseIllegalStateError("only tokens are supported")
val transferFeeToContractAmount = createTokenAmount(
token = tokenForPayFee,
value = BigDecimal(FEE_TRANSFER_AMOUNT),
)
val feeDestination = gaslessTransactionRepository.getTokenFeeReceiverAddress()
val feeTransferGasLimitResult = walletManager.getGasLimit(
amount = transferFeeToContractAmount,
destination = feeDestination,
callData = TransferERC20TokenCallData(
destination = feeDestination,
amount = transferFeeToContractAmount,
),
)
val feeTransferGasLimit = when (feeTransferGasLimitResult) {
is Result.Failure -> raise(GaslessError.DataError(feeTransferGasLimitResult.error))
is Result.Success -> feeTransferGasLimitResult.data
}.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas
val maxFeePerGas = when (initialFee) {
is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas
is Fee.Ethereum.Legacy -> initialFee.gasPrice
is Fee.Ethereum.TokenCurrency -> raiseIllegalStateError("initialFee could only be native")
}
val feeInNativeCurrency = BigDecimal(maxTokenFeeGas.multiply(maxFeePerGas))
.multiply(BigDecimal(GAS_PRICE_MULTIPLIER))
.setScale(0, RoundingMode.UP)
.toBigInteger()
val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null")
val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null")
val coinPriceInTokenInBigDecimal = nativeFiatRate.divide(
tokenFiatRate,
maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals),
RoundingMode.UP,
)
val coinPriceInTokenBigInt = coinPriceInTokenInBigDecimal
.movePointRight(tokenForPayFee.decimals)
.increaseByPercent(PERCENT_TO_INCREASE_TOKEN_PRICE)
.toBigInteger()
val feeInTokenCurrency = coinPriceInTokenInBigDecimal.multiply(
feeInNativeCurrency
.toBigDecimal()
.movePointLeft(nativeCurrencyStatus.currency.decimals),
)
val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO
if (tokenBalance < feeInTokenCurrency) {
raise(GaslessError.NotEnoughFunds)
}
val amount = createTokenAmount(tokenForPayFee, feeInTokenCurrency)
val fee = Fee.Ethereum.TokenCurrency(
amount = amount,
gasLimit = maxTokenFeeGas,
coinPriceInToken = coinPriceInTokenBigInt,
feeTransferGasLimit = feeTransferGasLimit,
baseGas = baseGas,
)
TransactionFeeExtended(
transactionFee = TransactionFee.Single(normal = fee),
feeTokenId = tokenForPayFee.id,
)
}
}
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
token = Token(
symbol = token.symbol,
contractAddress = token.contractAddress,
decimals = token.decimals,
),
value = value,
)
private suspend fun Raise<GetFeeError>.demoTransactionSender(
userWallet: UserWallet,
network: Network,
): DemoTransactionSender {
return DemoTransactionSender(
walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
?: raiseIllegalStateError("WalletManager is null"),
)
}
private companion object {
/** Amount in token units for fee transfer */
const val FEE_TRANSFER_AMOUNT = 0.01 // calculate using decimals
/** Gas price safety multiplier for fee calculation */
const val GAS_PRICE_MULTIPLIER = 1.5
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
/**
* Increases BigDecimal value by specified percentage.
*
* @param percent percentage to increase by (e.g., 1 for 1%, 10 for 10%)
* @return value increased by specified percentage (value * (1 + percent/100))
*
* Example:
* ```
* BigDecimal("100").increaseByPercent(1) // 101
* BigDecimal("100").increaseByPercent(10) // 110
* BigDecimal("100").increaseByPercent(50) // 150
* ```
*/
private fun BigDecimal.increaseByPercent(percent: Int): BigDecimal {
require(percent >= 0) { "Percent must be non-negative" }
val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100")))
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

@ -0,0 +1,474 @@
package com.tangem.domain.transaction.usecase.gasless
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.BlockchainSdkError
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.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.GaslessTransactionRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.coEvery
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 java.math.BigDecimal
import java.math.BigInteger
/**
* Unit tests for [TokenFeeCalculator].
* Tests cover fee calculation logic, error handling, and edge cases.
*/
class TokenFeeCalculatorTest {
private lateinit var walletManagersFacade: WalletManagersFacade
private lateinit var gaslessTransactionRepository: GaslessTransactionRepository
private lateinit var demoConfig: DemoConfig
private lateinit var tokenFeeCalculator: TokenFeeCalculator
private lateinit var mockWalletManager: EthereumWalletManager
private lateinit var mockNetwork: Network
private lateinit var mockUserWallet: UserWallet
private lateinit var mockUserWalletId: UserWalletId
private lateinit var mockTransactionData: TransactionData
@Before
fun setup() {
walletManagersFacade = mockk()
gaslessTransactionRepository = mockk()
demoConfig = mockk()
tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
)
mockWalletManager = mockk()
mockNetwork = mockk()
mockUserWallet = mockk<UserWallet.Hot>()
mockUserWalletId = mockk()
mockTransactionData = mockk()
// Default mock behavior
every { demoConfig.isDemoCardId(any()) } returns false
every { mockUserWallet.walletId } returns mockUserWalletId
}
// ===== calculateInitialFee Tests =====
@Test
fun `calculateInitialFee should return success when getFee succeeds`() = runTest {
// Given
val expectedFee = createMockTransactionFee()
coEvery { mockWalletManager.getFee(mockTransactionData) } returns Result.Success(expectedFee)
// When
val result = tokenFeeCalculator.calculateInitialFee(
userWallet = mockUserWallet,
network = mockNetwork,
walletManager = mockWalletManager,
transactionData = mockTransactionData,
)
// Then
assertTrue(result.isRight())
result.onRight { fee ->
assertEquals(expectedFee, fee)
}
coVerify { mockWalletManager.getFee(mockTransactionData) }
}
@Test
fun `calculateInitialFee should return error when getFee fails`() = runTest {
// Given
val failure = Result.Failure(BlockchainSdkError.Ethereum.Api(1, "Failed to get fee"))
coEvery { mockWalletManager.getFee(mockTransactionData) } returns failure
// When
val result = tokenFeeCalculator.calculateInitialFee(
userWallet = mockUserWallet,
network = mockNetwork,
walletManager = mockWalletManager,
transactionData = mockTransactionData,
)
// Then
assertTrue(result.isLeft())
result.onLeft { error ->
assertTrue(error is GetFeeError.GaslessError)
}
}
// ===== estimateInitialFee Tests =====
@Test
fun `estimateInitialFee should return success when estimation succeeds`() = runTest {
// Given
val amount = BigDecimal("100")
val tokenStatus = createMockTokenStatus()
val expectedFee = createMockTransactionFee()
coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns Result.Success(expectedFee)
// When
val result = tokenFeeCalculator.estimateInitialFee(
userWallet = mockUserWallet,
amount = amount,
txTokenCurrencyStatus = tokenStatus,
)
// Then
assertTrue(result.isRight())
result.onRight { fee ->
assertEquals(expectedFee, fee)
}
}
@Test
fun `estimateInitialFee should return error when estimation fails`() = runTest {
// Given
val amount = BigDecimal("100")
val tokenStatus = createMockTokenStatus()
val failure = Result.Failure(BlockchainSdkError.NPError("Estimation failed"))
coEvery {
walletManagersFacade.estimateFee(any(), any(), any())
} returns failure
// When
val result = tokenFeeCalculator.estimateInitialFee(
userWallet = mockUserWallet,
amount = amount,
txTokenCurrencyStatus = tokenStatus,
)
// Then
assertTrue(result.isLeft())
}
@Test
fun `estimateInitialFee should return error when result is null`() = runTest {
// Given
val amount = BigDecimal("100")
val tokenStatus = createMockTokenStatus()
coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns null
// When
val result = tokenFeeCalculator.estimateInitialFee(
userWallet = mockUserWallet,
amount = amount,
txTokenCurrencyStatus = tokenStatus,
)
// Then
assertTrue(result.isLeft())
result.onLeft { error ->
assertTrue(error is GetFeeError.UnknownError)
}
}
// ===== calculateTokenFee Tests =====
@Test
fun `calculateTokenFee should calculate correct fee for token payment`() = runTest {
// Given
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("1000"),
fiatRate = BigDecimal("1"), // 1 USDC = 1 USD
)
val nativeStatus = createMockNativeCurrencyStatus(
fiatRate = BigDecimal("2000"), // 1 ETH = 2000 USD
)
val initialFee = createMockEIP1559Fee()
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isRight())
result.onRight { feeExtended ->
assertNotNull(feeExtended)
assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId)
assertTrue(feeExtended.transactionFee is TransactionFee.Single)
}
}
@Test
fun `calculateTokenFee should return error when token balance is insufficient`() = runTest {
// Given
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"), // Very small balance
fiatRate = BigDecimal("1"),
)
val nativeStatus = createMockNativeCurrencyStatus(
fiatRate = BigDecimal("2000"),
)
val initialFee = createMockEIP1559Fee()
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isLeft())
result.onLeft { error ->
assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds)
}
}
@Test
fun `calculateTokenFee should return error when fiatRate is null`() = runTest {
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// Given
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("1000"),
fiatRate = null, // No fiat rate
)
val nativeStatus = createMockNativeCurrencyStatus(
fiatRate = BigDecimal("2000"),
)
val initialFee = createMockEIP1559Fee()
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isLeft())
}
@Test
fun `calculateTokenFee should return error when getGasLimit fails`() = runTest {
// Given
val tokenStatus = createMockTokenStatus()
val nativeStatus = createMockNativeCurrencyStatus()
val initialFee = createMockEIP1559Fee()
val failure = Result.Failure(BlockchainSdkError.NPError("Gas limit fetch failed"))
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns failure
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isLeft())
result.onLeft { error ->
assertTrue(error is GetFeeError.GaslessError.DataError)
}
}
@Test
fun `calculateTokenFee should handle Legacy fee type correctly`() = runTest {
// Given
val tokenStatus = createMockTokenStatus()
val nativeStatus = createMockNativeCurrencyStatus()
val initialFee = Fee.Ethereum.Legacy(
amount = mockk(relaxed = true),
gasLimit = BigInteger("100000"),
gasPrice = BigInteger("50000000000"), // 50 Gwei
)
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isRight())
}
@Test
fun `calculateTokenFee should reject TokenCurrency as initial fee`() = runTest {
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// Given
val tokenStatus = createMockTokenStatus()
val nativeStatus = createMockNativeCurrencyStatus()
val initialFee = Fee.Ethereum.TokenCurrency(
amount = mockk(relaxed = true),
gasLimit = BigInteger("100000"),
coinPriceInToken = BigInteger("1000"),
feeTransferGasLimit = BigInteger("60000"),
baseGas = BigInteger("21000"),
)
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isLeft())
}
@Test
fun `calculateTokenFee should apply 1 percent increase to token price`() = runTest {
// Expected
val expectedAmount = Amount(
value = BigDecimal("28.050000000000000000000000000000000000"),
token = Token(
name = "USDC",
symbol = "USDC",
contractAddress = "0xUSDC",
decimals = 6,
)
)
val expectedGasLimit = "187000".toBigInteger()
val expectedCoinPriceInToken = BigInteger("2020000000") // 2000 * 1.01 * 10^6
val expectedFeeTransferLimit = "66000".toBigInteger()
val expectedBaseGas = "21000".toBigInteger()
// Given
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("1000"),
fiatRate = BigDecimal("1"),
decimals = 6,
)
val nativeStatus = createMockNativeCurrencyStatus(
fiatRate = BigDecimal("2000"),
decimals = 18,
)
val initialFee = createMockEIP1559Fee()
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isRight())
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
assertEquals(expectedAmount, fee.amount)
assertEquals(expectedGasLimit, fee.gasLimit)
assertEquals(expectedCoinPriceInToken, fee.coinPriceInToken)
assertEquals(expectedFeeTransferLimit, fee.feeTransferGasLimit)
assertEquals(expectedBaseGas, fee.baseGas)
}
}
// ===== Helper Methods =====
private fun createMockTransactionFee(): TransactionFee {
val fee = Fee.Ethereum.EIP1559(
amount = mockk(relaxed = true),
gasLimit = BigInteger("100000"),
maxFeePerGas = BigInteger("50000000000"),
priorityFee = BigInteger("1000000000"),
)
return TransactionFee.Single(normal = fee)
}
private fun createMockEIP1559Fee(): Fee.Ethereum.EIP1559 {
return Fee.Ethereum.EIP1559(
amount = mockk(relaxed = true),
gasLimit = BigInteger("100000"),
maxFeePerGas = BigInteger("50000000000"), // 50 Gwei
priorityFee = BigInteger("1000000000"), // 1 Gwei
)
}
private fun createMockTokenStatus(
balance: BigDecimal = BigDecimal("1000"),
fiatRate: BigDecimal? = BigDecimal("1"),
decimals: Int = 6,
): CryptoCurrencyStatus {
val token = mockk<CryptoCurrency.Token>(relaxed = true)
every { token.symbol } returns "USDC"
every { token.contractAddress } returns "0xUSDC"
every { token.decimals } returns decimals
every { token.network } returns mockNetwork
every { token.id } returns mockk(relaxed = true)
val status = mockk<CryptoCurrencyStatus>()
every { status.currency } returns token
every { status.value.amount } returns balance
every { status.value.fiatRate } returns fiatRate
every { status.value.yieldSupplyStatus } returns null
return status
}
private fun createMockNativeCurrencyStatus(
fiatRate: BigDecimal? = BigDecimal("2000"),
decimals: Int = 18,
): CryptoCurrencyStatus {
val coin = mockk<CryptoCurrency.Coin>(relaxed = true)
every { coin.symbol } returns "ETH"
every { coin.decimals } returns decimals
every { coin.network } returns mockNetwork
val status = mockk<CryptoCurrencyStatus>()
every { status.currency } returns coin
every { status.value.fiatRate } returns fiatRate
every { status.value.yieldSupplyStatus } returns null
return status
}
}