Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-26 15:48:05 +04:00
parent 7e94a484a0
commit 80e33b892a
51 changed files with 2975 additions and 445 deletions

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.domain.transaction"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
@ -42,6 +46,8 @@ dependencies {
implementation(projects.domain.notifications)
api(projects.domain.networks)
testRuntimeOnly(deps.test.junit5.engine)
testRuntimeOnly(deps.test.junit5.vintage.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)

View file

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

View file

@ -3,6 +3,7 @@ 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.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData
import java.math.BigInteger
@ -57,6 +58,33 @@ interface GaslessTransactionRepository {
eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult
/**
* Sends a gasless BATCH transaction to the gasless service for signing and returns the signed result.
*
* Mirrors [signGaslessTransaction] but accepts multiple transactions executed in array order.
* Index 0 is the user's main transaction; subsequent entries are appended operations
* (e.g. a yield `withdraw` to cover the fee from staked balance).
*
* @param gaslessBatchTransactionData domain model containing:
* - transactions: ordered list of calls (to, value, data)
* - fee: token payment configuration
* - nonce: user's contract nonce to prevent replay attacks
* @param signature user's ECDSA signature of the batch transaction in hex format (0x...)
* @param userAddress user's Ethereum address (EOA or contract wallet)
* @param network blockchain network used to determine chainId for the request
* @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract
* @return [GaslessSignedTransactionResult] containing the fully signed transaction ready to broadcast
* @throws IllegalStateException if network is not supported or chainId cannot be determined
* @throws Exception if service returns error or network request fails
*/
suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult
/**
* Hardcoded value as baseGas
*/

View file

@ -0,0 +1,33 @@
package com.tangem.domain.transaction
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
/**
* Narrow repository interface used by [com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase]
* to query yield-module state without introducing a circular module dependency.
*
* [com.tangem.domain.yield.supply.YieldSupplyTransactionRepository] extends this interface.
*/
interface GaslessYieldRepository {
/** Returns the effective (liquid) protocol balance for [cryptoCurrency], or null if unavailable. */
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
/** Returns the yield-module contract address for [cryptoCurrency], or null if unavailable. */
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
/**
* Builds an upgrade-wrapped `withdraw(yieldToken, amount)` call data for the user's yield module.
* @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
* @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
*/
suspend fun createPartialWithdrawCallData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
amount: Amount,
): SmartContractCallData
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.transaction.models
import java.math.BigInteger
/**
* Domain model for a gasless BATCH transaction (EIP-712 primaryType `GaslessBatchTransaction`).
* Reuses [GaslessTransactionData.Transaction] and [GaslessTransactionData.Fee].
*
* @property transactions ordered list index 0 is the user's main transaction, subsequent entries
* are appended operations (e.g. the yield `withdraw`). Executed in array order.
* @property fee fee payment configuration.
* @property nonce nonce from the user's contract.
*/
data class GaslessBatchTransactionData(
val transactions: List<GaslessTransactionData.Transaction>,
val fee: GaslessTransactionData.Fee,
val nonce: BigInteger,
)

View file

@ -0,0 +1,39 @@
package com.tangem.domain.transaction.models
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigInteger
/**
* Resolved strategy for paying a gasless transaction fee. Produced by ResolveGaslessFeePlanUseCase,
* consumed by CreateAndSendGaslessTransactionUseCase.
*/
sealed interface GaslessFeePlan {
/** Pay in the native coin (enough native balance) — falls back to the standard fee. */
data class NativePay(val fee: Fee) : GaslessFeePlan
/** Pay the fee from the token's plain balance. */
data class TokenPay(
val feeToken: CryptoCurrency.Token,
val fee: Fee.Ethereum.TokenCurrency,
) : GaslessFeePlan
/**
* Pay the fee by first withdrawing the token from the user's yield module (appended as a second
* batch transaction). [withdrawCallData] is already upgrade-wrapped when the module needs an upgrade.
*
* Note: the executed on-chain withdraw amount is the (floor-rounded) value encoded inside
* [withdrawCallData]. [withdrawAmount] is a CEILING-rounded copy intended for DISPLAY (e.g. a future
* "X withdrawn from Yield" notification); it intentionally may exceed the executed amount by 1 base
* unit. Do NOT use [withdrawAmount] to build the on-chain call data.
*/
data class TokenPayWithYieldWithdraw(
val feeToken: CryptoCurrency.Token,
val fee: Fee.Ethereum.TokenCurrency,
val withdrawAmount: BigInteger,
val withdrawCallData: SmartContractCallData,
val yieldModuleAddress: String,
) : GaslessFeePlan
}

View file

@ -15,16 +15,11 @@ data class GaslessTransactionData(
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 gasLimit: BigInteger,
val data: ByteArray,
) {
override fun equals(other: Any?): Boolean {
@ -35,6 +30,7 @@ data class GaslessTransactionData(
if (to != other.to) return false
if (value != other.value) return false
if (gasLimit != other.gasLimit) return false
if (!data.contentEquals(other.data)) return false
return true
@ -43,6 +39,7 @@ data class GaslessTransactionData(
override fun hashCode(): Int {
var result = to.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + gasLimit.hashCode()
result = 31 * result + data.contentHashCode()
return result
}

View file

@ -2,8 +2,28 @@ package com.tangem.domain.transaction.models
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigInteger
data class TransactionFeeExtended(
val transactionFee: TransactionFee,
val feeTokenId: CryptoCurrency.ID,
/**
* Resolved gasless fee strategy. Non-null only for token-paid gasless fees; null for native fee.
* A null value is semantically equivalent to [GaslessFeePlan.NativePay] consumers MUST treat them
* the same. [GaslessFeePlan.NativePay] is produced only by ResolveGaslessFeePlanUseCase.
* When it is [GaslessFeePlan.TokenPayWithYieldWithdraw], the send step builds a batch transaction.
*/
val gaslessFeePlan: GaslessFeePlan? = null,
/**
* Per-call gas limit for the user's main transaction, bound into the v2 EIP-712 hash
* ([GaslessTransactionData.Transaction.gasLimit]). Non-null only on the token-fee (gasless) path,
* where it equals the estimated execution gas of the user's transaction.
*/
val mainTransactionGasLimit: BigInteger? = null,
/**
* Per-call gas limit for the appended yield-withdraw sub-call in a batch. Non-null only when the
* fee is paid via [GaslessFeePlan.TokenPayWithYieldWithdraw]; used as the withdraw transaction's
* [GaslessTransactionData.Transaction.gasLimit].
*/
val withdrawGasLimit: BigInteger? = null,
)

View file

@ -27,6 +27,8 @@ import com.tangem.domain.models.wallet.UserWallet
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.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessFeePlan
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -38,6 +40,7 @@ class CreateAndSendGaslessTransactionUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
private val isGaslessV2Enabled: Boolean,
) {
suspend operator fun invoke(
@ -69,6 +72,12 @@ class CreateAndSendGaslessTransactionUseCase(
/**
* Prepares all necessary context for gasless transaction.
* Includes: wallet manager, gasless provider, token status, nonce, transaction data.
*
* When the resolved fee plan is [GaslessFeePlan.TokenPayWithYieldWithdraw], the payload is a
* [GaslessPayload.Batch] with the user's main tx at index 0 and the yield-withdraw tx at index 1.
* [GaslessFeePlan.TokenPay] and a null plan produce a [GaslessPayload.Single] with the same
* single-transaction behavior as before. [GaslessFeePlan.NativePay] must never reach this use
* case it is guarded in [assembleGaslessPayload].
*/
private suspend fun prepareGaslessContext(
userWallet: UserWallet,
@ -91,11 +100,17 @@ class CreateAndSendGaslessTransactionUseCase(
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
val gaslessTransactionData = createGaslessTransactionData(
transactionData = transactionData,
txFee = fee,
currency = currency,
val mainTxGasLimit = fee.mainTransactionGasLimit
?: error("Main transaction gas limit is required for a gasless (token-fee) transaction")
val mainTx = buildTransaction(transactionData, mainTxGasLimit)
val feeObj = buildFee(fee, currency)
val payload = assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = gaslessContractNonce,
plan = fee.gaslessFeePlan,
withdrawGasLimit = fee.withdrawGasLimit,
)
val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network)
@ -104,7 +119,7 @@ class CreateAndSendGaslessTransactionUseCase(
walletManager = walletManager,
gaslessDataProvider = gaslessDataProvider,
currency = currency,
gaslessTransactionData = gaslessTransactionData,
payload = payload,
chainId = chainId,
)
}
@ -125,17 +140,30 @@ class CreateAndSendGaslessTransactionUseCase(
/**
* Signs gasless transaction and EIP-7702 authorization.
* Returns prepared signatures and authorization data.
*
* EIP-712 typed data is constructed from the payload:
* - [GaslessPayload.Single] [Eip712TypedDataBuilder.build] (single-transaction schema)
* - [GaslessPayload.Batch] [Eip712TypedDataBuilder.buildBatch] (batch schema)
*/
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 eip712Data = when (val payload = context.payload) {
is GaslessPayload.Single -> Eip712TypedDataBuilder.build(
gaslessTransaction = payload.data,
chainId = context.chainId,
verifyingContract = transactionData.sourceAddress,
includeGasLimit = isGaslessV2Enabled,
)
is GaslessPayload.Batch -> Eip712TypedDataBuilder.buildBatch(
gaslessBatch = payload.data,
chainId = context.chainId,
verifyingContract = transactionData.sourceAddress,
includeGasLimit = isGaslessV2Enabled,
)
}
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider)
@ -182,19 +210,34 @@ class CreateAndSendGaslessTransactionUseCase(
/**
* Sends gasless transaction to the service.
*
* Routes to the appropriate repository call based on payload type:
* - [GaslessPayload.Single] [GaslessTransactionRepository.signGaslessTransaction]
* - [GaslessPayload.Batch] [GaslessTransactionRepository.signGaslessBatchTransaction]
*
* Pending-transaction tracking is always keyed on the main (user's) transaction only.
*/
private suspend fun signAndSendTransactionOnBackend(
context: GaslessContext,
signedData: SignedGaslessData,
transactionData: TransactionData.Uncompiled,
): String {
val txHash = gaslessTransactionRepository.signGaslessTransaction(
network = context.currency.network,
gaslessTransactionData = context.gaslessTransactionData,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
val txHash = when (val payload = context.payload) {
is GaslessPayload.Single -> gaslessTransactionRepository.signGaslessTransaction(
network = context.currency.network,
gaslessTransactionData = payload.data,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
is GaslessPayload.Batch -> gaslessTransactionRepository.signGaslessBatchTransaction(
network = context.currency.network,
gaslessBatchTransactionData = payload.data,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
}
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
transactionData = transactionData,
@ -241,23 +284,10 @@ class CreateAndSendGaslessTransactionUseCase(
}
}
private suspend fun createGaslessTransactionData(
private fun buildTransaction(
transactionData: TransactionData.Uncompiled,
txFee: TransactionFeeExtended,
currency: CryptoCurrency,
nonce: BigInteger,
): GaslessTransactionData {
val transaction = buildTransaction(transactionData)
val fee = buildFee(txFee, currency)
return GaslessTransactionData(
transaction = transaction,
fee = fee,
nonce = nonce,
)
}
private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction {
gasLimit: BigInteger,
): GaslessTransactionData.Transaction {
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
?: error("Ethereum call data is required")
@ -268,6 +298,7 @@ class CreateAndSendGaslessTransactionUseCase(
return GaslessTransactionData.Transaction(
to = getDestinationAddress(transactionData),
value = nativeAmount,
gasLimit = gasLimit,
data = callData.data,
)
}
@ -295,20 +326,28 @@ class CreateAndSendGaslessTransactionUseCase(
private suspend fun getEIP7702DataForGasless(
gaslessDataProvider: EthereumGaslessDataProvider,
): EIP7702AuthorizationData {
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = false)) {
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = isGaslessV2Enabled)) {
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")
}
/**
* Discriminated union of the gasless transaction payload to sign and send.
*
* [Single] carries a single-transaction payload (the pre-existing path).
* [Batch] carries a batch payload where the yield-withdraw call is appended as the second
* transaction so that staked tokens are unlocked before the fee is settled.
*/
internal sealed interface GaslessPayload {
/** Single-transaction path — behavior is identical to the original implementation. */
data class Single(val data: GaslessTransactionData) : GaslessPayload
/**
* Batch path used when [GaslessFeePlan.TokenPayWithYieldWithdraw] is resolved.
* [data.transactions] has the user's main tx at index 0 and the withdraw tx at index 1.
*/
data class Batch(val data: GaslessBatchTransactionData) : GaslessPayload
}
/**
@ -318,7 +357,7 @@ class CreateAndSendGaslessTransactionUseCase(
val walletManager: WalletManager,
val gaslessDataProvider: EthereumGaslessDataProvider,
val currency: CryptoCurrency,
val gaslessTransactionData: GaslessTransactionData,
val payload: GaslessPayload,
val chainId: Int,
)
@ -353,9 +392,75 @@ class CreateAndSendGaslessTransactionUseCase(
}
}
private companion object {
internal companion object {
/**
* Assembles the [GaslessPayload] from already-built domain objects and the resolved fee plan.
*
* Dispatch rules:
* - [GaslessFeePlan.TokenPayWithYieldWithdraw] [GaslessPayload.Batch]: the yield-withdraw
* call is appended as the second transaction so that the fee token balance is topped up
* before the gasless service processes the fee.
* - [GaslessFeePlan.TokenPay] or `null` [GaslessPayload.Single]: single-transaction path,
* identical to the original implementation. `null` is a legitimate value meaning the plan
* was not explicitly resolved.
* - [GaslessFeePlan.NativePay] error: native-pay fees must never reach this use case
* (they are handled by the standard send path).
*/
internal fun assembleGaslessPayload(
mainTx: GaslessTransactionData.Transaction,
feeObj: GaslessTransactionData.Fee,
nonce: BigInteger,
plan: GaslessFeePlan?,
withdrawGasLimit: BigInteger?,
): GaslessPayload = when (plan) {
is GaslessFeePlan.TokenPayWithYieldWithdraw -> GaslessPayload.Batch(
GaslessBatchTransactionData(
transactions = listOf(
mainTx,
GaslessTransactionData.Transaction(
to = plan.yieldModuleAddress,
value = BigInteger.ZERO,
gasLimit = withdrawGasLimit
?: error("Withdraw gas limit is required for a yield-withdraw batch"),
data = plan.withdrawCallData.data,
),
),
fee = feeObj,
nonce = nonce,
),
)
is GaslessFeePlan.TokenPay, null -> GaslessPayload.Single(
GaslessTransactionData(transaction = mainTx, fee = feeObj, nonce = nonce),
)
is GaslessFeePlan.NativePay -> error("NativePay must not reach the gasless send path")
}
fun BigInteger.toFormattedHex(bytes: Int): String {
return toByteArray().normalizeByteArray(bytes).toHexString().formatHex()
}
/**
* Resolves the on-chain `to` for the user's main gasless sub-call.
*
* - Yield-supply send (`EthereumYieldSupplySendCallData`, selector 0x0779afe6): `send(token, dest,
* amount)` is a method ON the user's yield module the executor must CALL the module (it holds the
* staked funds and routes the transfer); the recipient is already encoded inside the call data.
* [TransactionData.Uncompiled.destinationAddress] is patched to the module address in
* `DefaultTransactionRepository.createTransaction`, mirroring the non-gasless send path (and the
* withdraw sub-call's `to`). Reading `ethereumCallData.destinationAddress` (the recipient) instead
* makes the executor call a plain address with the module's calldata, reverting the whole batch with
* GAS_ESTIMATION_FAILED / require(false).
* - Otherwise (e.g. ERC-20 transfer): `to` is the contract the calldata runs against
* ([TransactionData.Uncompiled.contractAddress], the token contract).
*/
internal fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
txData.destinationAddress
} else {
txData.contractAddress ?: error("supports only Token transaction with contract address")
}
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.transaction.usecase.gasless
import com.tangem.common.extensions.toHexString
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONArray
import org.json.JSONObject
@ -26,6 +27,7 @@ object Eip712TypedDataBuilder {
private const val DOMAIN_NAME = "Tangem7702GaslessExecutor"
private const val DOMAIN_VERSION = "1"
private const val PRIMARY_TYPE = "GaslessTransaction"
private const val PRIMARY_TYPE_BATCH = "GaslessBatchTransaction"
/**
* Builds EIP-712 typed data JSON for gasless transaction.
@ -35,47 +37,106 @@ object Eip712TypedDataBuilder {
* @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 {
fun build(
gaslessTransaction: GaslessTransactionData,
chainId: Int,
verifyingContract: String,
includeGasLimit: Boolean = true,
): String {
val typedData = JSONObject().apply {
put("types", buildTypes())
put("types", buildTypes(includeGasLimit))
put("primaryType", PRIMARY_TYPE)
put("domain", buildDomain(chainId, verifyingContract))
put("message", buildMessage(gaslessTransaction))
put("message", buildMessage(gaslessTransaction, includeGasLimit))
}
return typedData.toString()
}
/**
* Builds EIP-712 typed data JSON for gasless batch transaction.
*
* @param gaslessBatch domain model with ordered list of transactions 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 buildBatch(
gaslessBatch: GaslessBatchTransactionData,
chainId: Int,
verifyingContract: String,
includeGasLimit: Boolean = true,
): String {
require(
gaslessBatch.transactions.isNotEmpty(),
) { "GaslessBatchTransaction must contain at least one transaction" }
val typedData = JSONObject().apply {
put("types", buildBatchTypes(includeGasLimit))
put("primaryType", PRIMARY_TYPE_BATCH)
put("domain", buildDomain(chainId, verifyingContract))
put("message", buildBatchMessage(gaslessBatch, includeGasLimit))
}
return typedData.toString()
}
/**
* Builds the type definitions for all structures in the batch variant.
* Uses `Transaction[]` for the ordered transactions array.
*/
private fun buildBatchTypes(includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("EIP712Domain", buildEip712DomainTypeProperties())
put("Transaction", buildTransactionTypeProperties(includeGasLimit))
put("Fee", buildFeeTypeProperties())
put("GaslessBatchTransaction", buildGaslessBatchTransactionTypeProperties())
}
}
private fun buildGaslessBatchTransactionTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("transactions", "Transaction[]"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
}
}
/**
* Builds the message data from gasless batch transaction.
*/
private fun buildBatchMessage(gaslessBatch: GaslessBatchTransactionData, includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("transactions", buildTransactionsArray(gaslessBatch.transactions, includeGasLimit))
put("fee", buildFeeMessage(gaslessBatch.fee))
put("nonce", gaslessBatch.nonce.toString())
}
}
private fun buildTransactionsArray(
transactions: List<GaslessTransactionData.Transaction>,
includeGasLimit: Boolean,
): JSONArray {
return JSONArray().apply {
transactions.forEach { tx -> put(buildTransactionMessage(tx, includeGasLimit)) }
}
}
/**
* 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 {
private fun buildTypes(includeGasLimit: Boolean): 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"))
})
put("EIP712Domain", buildEip712DomainTypeProperties())
put("Transaction", buildTransactionTypeProperties(includeGasLimit))
put("Fee", buildFeeTypeProperties())
put("GaslessTransaction", buildGaslessTransactionTypeProperties())
}
}
private fun buildGaslessTransactionTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("transaction", "Transaction"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
}
}
@ -104,23 +165,71 @@ object Eip712TypedDataBuilder {
/**
* Builds the message data from gasless transaction.
*/
@Suppress("NestedScopeFunctions")
private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject {
private fun buildMessage(gaslessTransaction: GaslessTransactionData, includeGasLimit: Boolean): 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("transaction", buildTransactionMessage(gaslessTransaction.transaction, includeGasLimit))
put("fee", buildFeeMessage(gaslessTransaction.fee))
put("nonce", gaslessTransaction.nonce.toString())
}
}
private fun buildTransactionMessage(
transaction: GaslessTransactionData.Transaction,
includeGasLimit: Boolean,
): JSONObject {
return JSONObject().apply {
put("to", transaction.to)
put("value", transaction.value.toString())
if (includeGasLimit) put("gasLimit", transaction.gasLimit.toString())
put("data", transaction.data.toHexString())
}
}
// region Shared type schema helpers
private fun buildEip712DomainTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("name", "string"))
put(typeProperty("version", "string"))
put(typeProperty("chainId", "uint256"))
put(typeProperty("verifyingContract", "address"))
}
}
private fun buildTransactionTypeProperties(includeGasLimit: Boolean): JSONArray {
return JSONArray().apply {
put(typeProperty("to", "address"))
put(typeProperty("value", "uint256"))
if (includeGasLimit) put(typeProperty("gasLimit", "uint256"))
put(typeProperty("data", "bytes"))
}
}
private fun buildFeeTypeProperties(): JSONArray {
return 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"))
}
}
// endregion
// region Shared message helpers
private fun buildFeeMessage(fee: GaslessTransactionData.Fee): JSONObject {
return JSONObject().apply {
put("feeToken", fee.feeToken)
put("maxTokenFee", fee.maxTokenFee.toString())
put("coinPriceInToken", fee.coinPriceInToken.toString())
put("feeTransferGasLimit", fee.feeTransferGasLimit.toString())
put("baseGas", fee.baseGas.toString())
put("feeReceiver", fee.feeReceiver)
}
}
// endregion
}

View file

@ -18,12 +18,14 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
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 com.tangem.utils.extensions.isZero
import java.math.BigDecimal
@Suppress("LongParameterList")
@ -31,6 +33,7 @@ class EstimateFeeForGaslessTxUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val estimateFeeUseCase: EstimateFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
@ -40,6 +43,7 @@ class EstimateFeeForGaslessTxUseCase(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -153,11 +157,11 @@ class EstimateFeeForGaslessTxUseCase(
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress
(currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
}.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
.filterNot { it.value.amount?.isZero() == true || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount }
.filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -22,18 +23,22 @@ import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
@Suppress("LongParameterList")
class EstimateFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val currencyChecksRepository: CurrencyChecksRepository,
private val isYieldWithdrawEnabled: Boolean,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -70,11 +75,15 @@ class EstimateFeeForTokenUseCase(
val walletManager = prepareWalletManager(userWallet, token.network)
val isYieldActive = isYieldWithdrawEnabled &&
feeTokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = feeTokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
isYieldActive = isYieldActive,
).bind()
},
catch = {

View file

@ -19,6 +19,7 @@ class GetAvailableFeeTokensUseCase(
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository,
private val isYieldWithdrawEnabled: Boolean,
) {
/**
@ -69,7 +70,7 @@ class GetAvailableFeeTokensUseCase(
}.toSet()
return userCurrenciesStatuses
.asSequence()
.filter { it.value.yieldSupplyStatus == null }
.filter { isEligibleFeeToken(it, isYieldWithdrawEnabled) }
.filter { it.currency.network.id == network.id }
.filter { currencyStatus ->
val token = currencyStatus.currency
@ -77,4 +78,12 @@ class GetAvailableFeeTokensUseCase(
}
.toList()
}
internal companion object {
internal fun isEligibleFeeToken(status: CryptoCurrencyStatus, isYieldWithdrawEnabled: Boolean): Boolean {
val yieldSupplyStatus = status.value.yieldSupplyStatus ?: return true
return isYieldWithdrawEnabled && yieldSupplyStatus.isActive
}
}
}

View file

@ -6,6 +6,7 @@ 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.AmountType
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
@ -19,6 +20,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -32,15 +34,19 @@ class GetFeeForGaslessUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getFeeUseCase: GetFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
private val isYieldWithdrawEnabled: Boolean,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -80,11 +86,13 @@ class GetFeeForGaslessUseCase(
).bind()
selectFeePaymentStrategy(
userWallet = userWallet,
accountStatusList = accountStatusList,
walletManager = walletManager,
nativeCurrencyStatus = nativeCurrencyStatus,
network = network,
initialFee = initialFee,
transactionData = transactionData,
)
},
catch = {
@ -108,12 +116,15 @@ class GetFeeForGaslessUseCase(
return ethereumWalletManager
}
@Suppress("LongParameterList")
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
userWallet: UserWallet,
accountStatusList: AccountStatusList,
walletManager: EthereumWalletManager,
nativeCurrencyStatus: CryptoCurrencyStatus,
network: Network,
initialFee: TransactionFee,
transactionData: TransactionData,
): TransactionFeeExtended {
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
@ -128,10 +139,12 @@ class GetFeeForGaslessUseCase(
nativeCoinSelectedResult
} else {
findTokensToPayFee(
userWallet = userWallet,
walletManager = walletManager,
initialTxFee = initialFee,
nativeCurrencyStatus = nativeCurrencyStatus,
networkCurrenciesStatuses = networkCurrenciesStatuses,
transactionData = transactionData,
).getOrElse { error ->
when (error) {
GaslessError.NotEnoughFunds -> nativeCoinSelectedResult
@ -141,12 +154,14 @@ class GetFeeForGaslessUseCase(
}
}
@Suppress("NullableToStringCall")
@Suppress("NullableToStringCall", "LongParameterList")
private suspend fun findTokensToPayFee(
userWallet: UserWallet,
walletManager: EthereumWalletManager,
initialTxFee: TransactionFee,
nativeCurrencyStatus: CryptoCurrencyStatus,
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
transactionData: TransactionData,
): Either<GetFeeError, TransactionFeeExtended> = either {
val initialFee = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
@ -156,29 +171,109 @@ class GetFeeForGaslessUseCase(
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress
(currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
}.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.
* Yield-aware candidate selection:
* a token is eligible if it is a supported gasless token AND
* (total balance > 0 OR has an active yield position).
* Sorted by total balance descending to maximise chances of covering the fee. For a yield token
* value.amount is already effectiveBalance (liquid EOA + effectiveProtocolBalance), so it must NOT
* be summed with effectiveProtocolBalance again that would double-count the module portion.
*/
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
?: raise(GaslessError.NoSupportedTokensFound)
val candidates = networkCurrenciesStatuses
.asSequence()
.filter { it.currency is CryptoCurrency.Token }
.filter { (it.currency as CryptoCurrency.Token).contractAddress.lowercase() in supportedGaslessTokens }
.filter { status ->
val total = status.value.amount ?: BigDecimal.ZERO
total > BigDecimal.ZERO || isYieldWithdrawEnabled && status.value.yieldSupplyStatus?.isActive == true
}
.sortedByDescending { status -> status.value.amount ?: BigDecimal.ZERO }
return tokenFeeCalculator.calculateTokenFee(
val tokenForPayFeeStatus = candidates.firstOrNull() ?: raise(GaslessError.NoSupportedTokensFound)
val isYieldActive = isYieldWithdrawEnabled && tokenForPayFeeStatus.value.yieldSupplyStatus?.isActive == true
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenForPayFeeStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFee,
isYieldActive = isYieldActive,
userWallet = userWallet,
).bind()
attachGaslessFeePlan(
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
userWallet = userWallet,
tokenStatus = tokenForPayFeeStatus,
tokenFeeExtended = tokenFeeExtended,
transactionData = transactionData,
isYieldActive = isYieldActive,
)
}
}
/**
* Resolves the [com.tangem.domain.transaction.models.GaslessFeePlan] for [tokenStatus] paying the gasless
* fee and attaches it to [tokenFeeExtended]. Shared by the auto path ([GetFeeForGaslessUseCase]) and the
* manual fee-token selection path ([GetFeeForTokenUseCase]) so both produce identical plans.
*/
@Suppress("LongParameterList")
internal suspend fun Raise<GetFeeError>.attachGaslessFeePlan(
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
userWallet: UserWallet,
tokenStatus: CryptoCurrencyStatus,
tokenFeeExtended: TransactionFeeExtended,
transactionData: TransactionData,
isYieldActive: Boolean,
): TransactionFeeExtended {
val feeInTokenCurrency = tokenFeeExtended.transactionFee.normal as? Fee.Ethereum.TokenCurrency
?: raiseIllegalStateError("gasless token fee must be Fee.Ethereum.TokenCurrency")
val feeTokenContract = (tokenStatus.currency as? CryptoCurrency.Token)?.contractAddress
?: raiseIllegalStateError("gasless fee currency must be a token")
val plan = resolveGaslessFeePlanUseCase(
userWallet = userWallet,
tokenStatus = tokenStatus,
tokenFee = feeInTokenCurrency,
isYieldActive = isYieldActive,
sendAmountInFeeToken = computeSendAmountInFeeToken(transactionData, feeTokenContract),
).bind()
return tokenFeeExtended.copy(gaslessFeePlan = plan)
}
/**
* Computes how much of the fee token is also being spent in the main transaction body.
*
* Gasless token-fee transactions MUST supply uncompiled data (the resolver needs the raw amount to
* account for it in the required-balance check). A compiled tx or a null sent amount on the
* matching-token path are both programmer errors, so they raise loudly instead of silently
* under-accounting as ZERO.
*
* @param transactionData the raw transaction data passed into [GetFeeForGaslessUseCase].
* @param feeTokenContract the contract address of the token selected to pay the gasless fee.
* @return the sent amount when [feeTokenContract] matches the sent-token contract,
* or [BigDecimal.ZERO] when a different token is being sent.
*/
internal fun Raise<GetFeeError>.computeSendAmountInFeeToken(
transactionData: TransactionData,
feeTokenContract: String,
): BigDecimal {
// Gasless token-fee requires uncompiled tx data (mirrors CreateAndSendGaslessTransactionUseCase).
val uncompiled = transactionData as? TransactionData.Uncompiled
?: raiseIllegalStateError("gasless token fee requires uncompiled transaction data")
val sentTokenContract = when (val type = uncompiled.amount.type) {
is AmountType.Token -> type.token.contractAddress
is AmountType.TokenYieldSupply -> type.token.contractAddress
else -> null
}
return if (sentTokenContract != null && sentTokenContract.equals(feeTokenContract, ignoreCase = true)) {
uncompiled.amount.value
?: raiseIllegalStateError("sent amount is null while paying the gasless fee in the sent token")
} else {
BigDecimal.ZERO
}
}

View file

@ -17,24 +17,30 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
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
@Suppress("LongParameterList")
class GetFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val currencyChecksRepository: CurrencyChecksRepository,
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
private val isYieldWithdrawEnabled: Boolean,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -74,12 +80,30 @@ class GetFeeForTokenUseCase(
raiseIllegalStateError("Token currency not found for network ${token.network.id}")
}
tokenFeeCalculator.calculateTokenFee(
val isYieldActive = isYieldWithdrawEnabled &&
tokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
isYieldActive = isYieldActive,
userWallet = userWallet,
).bind()
if (isYieldActive) {
attachGaslessFeePlan(
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
userWallet = userWallet,
tokenStatus = tokenCurrencyStatus,
tokenFeeExtended = tokenFeeExtended,
transactionData = transactionData,
isYieldActive = true,
)
} else {
tokenFeeExtended
}
},
catch = {
raise(GaslessError.DataError(it))

View file

@ -0,0 +1,97 @@
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.common.Amount
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
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.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.GaslessFeePlan
import java.math.BigDecimal
import java.math.RoundingMode
class ResolveGaslessFeePlanUseCase(
private val gaslessYieldRepository: GaslessYieldRepository,
) {
suspend operator fun invoke(
userWallet: UserWallet,
tokenStatus: CryptoCurrencyStatus,
tokenFee: Fee.Ethereum.TokenCurrency,
isYieldActive: Boolean,
sendAmountInFeeToken: BigDecimal,
): Either<GetFeeError, GaslessFeePlan> = either {
val token = tokenStatus.currency as? CryptoCurrency.Token
?: raise(GaslessError.DataError(IllegalStateException("fee currency must be a token")))
val feeAmount = tokenFee.amount.value
?: raise(GaslessError.DataError(IllegalStateException("token fee amount is null")))
val totalBalance = tokenStatus.value.amount ?: BigDecimal.ZERO
val required = feeAmount + sendAmountInFeeToken
if (!isYieldActive) {
return@either if (totalBalance >= required) {
GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
} else {
raise(GaslessError.NotEnoughFunds)
}
}
val moduleBalance = gaslessYieldRepository
.getEffectiveProtocolBalance(userWallet.walletId, token) ?: BigDecimal.ZERO
// Liquid balance already on the EOA = total - what is held inside the yield module.
val liquidBalance = (totalBalance - moduleBalance).coerceAtLeast(BigDecimal.ZERO)
if (liquidBalance >= required) {
return@either GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
}
if (totalBalance < required) raise(GaslessError.NotEnoughFunds)
val liquidLeftForFee = (liquidBalance - sendAmountInFeeToken).coerceAtLeast(BigDecimal.ZERO)
val withdrawAmountDecimal = (feeAmount - liquidLeftForFee).coerceAtLeast(BigDecimal.ZERO)
val withdrawCallData = catch(
block = {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = userWallet.walletId,
cryptoCurrency = token,
amount = Amount(
token = Token(token.symbol, token.contractAddress, token.decimals),
value = withdrawAmountDecimal,
),
)
},
catch = { error ->
when (error) {
is YieldModuleUpgradeUnavailableException,
is YieldModuleVersionIndeterminateException,
-> raise(GaslessError.ModuleUpdateUnavailable)
else -> raise(GaslessError.DataError(error))
}
},
)
val yieldModuleAddress = gaslessYieldRepository
.getYieldContractAddress(userWallet.walletId, token)
?: raise(GaslessError.DataError(IllegalStateException("yield module address is null")))
GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = token,
fee = tokenFee,
withdrawAmount = withdrawAmountDecimal
.movePointRight(token.decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger(),
withdrawCallData = withdrawCallData,
yieldModuleAddress = yieldModuleAddress,
)
}
}

View file

@ -6,12 +6,15 @@ 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.AmountType
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.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
@ -19,6 +22,7 @@ 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.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -34,6 +38,7 @@ internal class TokenFeeCalculator(
private val walletManagersFacade: WalletManagersFacade,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val demoConfig: DemoConfig,
private val gaslessYieldRepository: GaslessYieldRepository,
) {
suspend fun calculateInitialFee(
@ -90,16 +95,19 @@ internal class TokenFeeCalculator(
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Suppress("LongMethod", "CyclomaticComplexity")
suspend fun calculateTokenFee(
walletManager: EthereumWalletManager,
tokenForPayFeeStatus: CryptoCurrencyStatus,
nativeCurrencyStatus: CryptoCurrencyStatus,
initialFee: Fee.Ethereum,
isYieldActive: Boolean = false,
userWallet: UserWallet? = null,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
// fast finish to skip calculations if no funds in token
if (tokenForPayFeeStatus.value.amount?.isZero() == true) {
// fast finish to skip calculations if no funds in token.
// Skipped on the yield path: a zero plain balance is expected — it will be topped up from yield.
if (!isYieldActive && tokenForPayFeeStatus.value.amount?.isZero() == true) {
raise(GaslessError.NotEnoughFunds)
}
@ -120,23 +128,16 @@ internal class TokenFeeCalculator(
),
)
val feeTransferGasLimit = when (feeTransferGasLimitResult) {
is Result.Success -> feeTransferGasLimitResult.data
is Result.Failure -> {
// If there is a dust on the balance, the gas limit estimation will fail with code
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
val cause = feeTransferGasLimitResult.error.cause
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
raise(GaslessError.NotEnoughFunds)
}
}
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
}.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
val feeTransferGasLimit = resolveFeeTransferGasLimit(feeTransferGasLimitResult, isYieldActive)
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas
val withdrawGas = if (isYieldActive) {
estimateWithdrawGasLimit(userWallet, walletManager, tokenForPayFee)
} else {
BigInteger.ZERO
}
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + withdrawGas
val maxFeePerGas = when (initialFee) {
is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas
@ -170,7 +171,8 @@ internal class TokenFeeCalculator(
)
val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO
if (tokenBalance < feeInTokenCurrency) {
// Skipped on the yield path: ResolveGaslessFeePlanUseCase decides plain-vs-yield coverage.
if (!isYieldActive && tokenBalance < feeInTokenCurrency) {
raise(GaslessError.NotEnoughFunds)
}
@ -186,10 +188,97 @@ internal class TokenFeeCalculator(
TransactionFeeExtended(
transactionFee = TransactionFee.Single(normal = fee),
feeTokenId = tokenForPayFee.id,
// Per-call gas limits for the v2 gasless meta-tx (bound into the EIP-712 hash).
// Main = the user's transaction execution gas; withdraw = the appended yield-withdraw
// sub-call gas, present only on the yield path where a batch is built.
mainTransactionGasLimit = initialFee.gasLimit,
withdrawGasLimit = withdrawGas.takeIf { isYieldActive },
)
}
}
/**
* Resolves the fee-transfer gas limit from the on-chain estimation result.
*
* On the yield path ([isYieldActive] = true), when the estimation reverts with
* [BlockchainSdkError.Ethereum.InsufficientFundsForOperation] (expected for a zero plain balance),
* falls back to [FALLBACK_FEE_TRANSFER_GAS_LIMIT] instead of raising [GaslessError.NotEnoughFunds].
* All other failures propagate as [GaslessError.DataError] on both paths.
*/
private fun Raise<GetFeeError>.resolveFeeTransferGasLimit(
feeTransferGasLimitResult: Result<BigInteger>,
isYieldActive: Boolean,
): BigInteger {
val rawFeeTransferGasLimit: BigInteger = when (feeTransferGasLimitResult) {
is Result.Success -> feeTransferGasLimitResult.data
is Result.Failure -> {
// If there is a dust on the balance, the gas limit estimation will fail with code
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
val cause = feeTransferGasLimitResult.error.cause
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
if (isYieldActive) {
FALLBACK_FEE_TRANSFER_GAS_LIMIT
} else {
raise(GaslessError.NotEnoughFunds)
}
} else {
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
} else {
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
}
}
return rawFeeTransferGasLimit.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
}
@Suppress("SwallowedException")
private suspend fun estimateWithdrawGasLimit(
userWallet: UserWallet?,
walletManager: EthereumWalletManager,
token: CryptoCurrency.Token,
): BigInteger {
if (userWallet == null) return WITHDRAW_GAS_LIMIT
val moduleAddress = gaslessYieldRepository.getYieldContractAddress(userWallet.walletId, token)
?: return WITHDRAW_GAS_LIMIT
// The withdraw amount is encoded into the call data: a small fixed probe whose exact value does not
// affect the gas cost. It is a token amount because the call data needs the token's contract/decimals.
val withdrawAmount = createTokenAmount(
token = token,
value = BigDecimal(PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS).movePointLeft(token.decimals),
)
val probeCallData = try {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = userWallet.walletId,
cryptoCurrency = token,
amount = withdrawAmount,
)
} catch (e: YieldModuleUpgradeUnavailableException) {
return WITHDRAW_GAS_LIMIT
} catch (e: YieldModuleVersionIndeterminateException) {
return WITHDRAW_GAS_LIMIT
}
// Mirrors the real batch sub-call (see CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload):
// `to = moduleAddress`, zero native value, withdraw call data. A zero-value Coin amount is required so
// that EthereumWalletManager.getGasLimit keeps `to` = moduleAddress — a Token amount would override it
// with the token contract address and estimate the wrong call.
val estimationAmount = Amount(
currencySymbol = token.symbol,
value = BigDecimal.ZERO,
decimals = token.decimals,
type = AmountType.Coin,
)
return when (val result = walletManager.getGasLimit(estimationAmount, moduleAddress, probeCallData)) {
is Result.Success -> result.data
is Result.Failure -> WITHDRAW_GAS_LIMIT
}
}
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
token = Token(
symbol = token.symbol,
@ -217,6 +306,26 @@ internal class TokenFeeCalculator(
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
/**
* Fallback gas for the batch yield-withdraw operation (withdraw + possible module upgrade), used when
* the on-chain probe estimation in [estimateWithdrawGasLimit] is unavailable or reverts. Overestimate-safe
* because it only inflates maxTokenFee (a cap) and the signed per-call gas limit.
*/
val WITHDRAW_GAS_LIMIT: BigInteger = BigInteger("150000")
/**
* Probe amount (in the fee token's minimal units) for the `withdraw` gas estimation. Per spec it is a
* small fixed value: large enough to simulate a real withdraw, small enough not to exceed the yield
* balance. The withdraw gas cost is effectively independent of the amount.
*/
const val PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS = 10_000L
/**
* Fallback fee-transfer gas limit used when on-chain estimation reverts due to a zero plain balance on the
* yield path. TODO: tune against testnet if needs.
*/
val FALLBACK_FEE_TRANSFER_GAS_LIMIT: BigInteger = BigInteger("100000")
/**
* Increases BigDecimal value by specified percentage.
*

View file

@ -0,0 +1,25 @@
package com.tangem.domain.transaction.models
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class GaslessBatchTransactionDataTest {
@Test
fun `holds transactions fee and nonce`() {
val tx = GaslessTransactionData.Transaction(
to = "0xabc", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(1),
)
val withdraw = GaslessTransactionData.Transaction(
to = "0xdef", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(2),
)
val fee = GaslessTransactionData.Fee(
feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv",
)
val batch = GaslessBatchTransactionData(transactions = listOf(tx, withdraw), fee = fee, nonce = BigInteger.ZERO)
assertThat(batch.transactions).hasSize(2)
assertThat(batch.transactions[1]).isEqualTo(withdraw)
}
}

View file

@ -0,0 +1,155 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.raise.either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.transaction.error.GetFeeError
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Unit tests for [computeSendAmountInFeeToken].
*
* Cases:
* (a) Different token ZERO (fee token sent token).
* (b) Same token via AmountType.Token the actual sent amount.
* (c) Same token via AmountType.TokenYieldSupply the actual sent amount.
* (d) Same token but amount.value == null raises (loud error, never silent ZERO).
* (e) Compiled tx raises (gasless token-fee requires uncompiled data).
*/
class ComputeSendAmountInFeeTokenTest {
private val feeContract = "0xUSDC"
private val otherContract = "0xDAI"
private val sentAmount = BigDecimal("50.0")
private fun makeToken(contract: String) = Token(
name = "TestToken",
symbol = "TST",
contractAddress = contract,
decimals = 6,
)
private fun uncompiledWith(type: AmountType, value: BigDecimal?) = TransactionData.Uncompiled(
amount = Amount(
currencySymbol = "TST",
value = value,
maxValue = null,
decimals = 6,
type = type,
),
sourceAddress = "0xSrc",
destinationAddress = "0xDst",
fee = null,
)
// (a) Sent token is different from fee token → ZERO
@Test
fun `returns ZERO when sent token differs from fee token`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(otherContract)),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(BigDecimal.ZERO, result.getOrNull())
}
// (b) AmountType.Token — same contract as fee token → returns the sent amount
@Test
fun `returns sent amount when AmountType Token matches fee token contract`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract)),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (b) Case-insensitive contract address match
@Test
fun `contract address comparison is case-insensitive`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract.uppercase())),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract.lowercase())
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (c) AmountType.TokenYieldSupply — same contract as fee token → returns the sent amount
@Test
fun `returns sent amount when AmountType TokenYieldSupply matches fee token contract`() {
val tx = uncompiledWith(
type = AmountType.TokenYieldSupply(
token = makeToken(feeContract),
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (d) Same token but amount.value == null → raises (never silently under-accounts as ZERO)
@Test
fun `raises when same token is sent but amount value is null`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract)),
value = null,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isLeft(), "Expected Left (error) when sent amount is null")
assertTrue(
result.leftOrNull() is GetFeeError.DataError,
"Expected GetFeeError.DataError wrapping IllegalStateException",
)
}
// (e) Compiled tx → raises (gasless token-fee requires uncompiled data)
@Test
fun `raises when transactionData is Compiled`() {
val compiled = TransactionData.Compiled(
value = TransactionData.Compiled.Data.Bytes(byteArrayOf(0x01, 0x02)),
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(compiled, feeContract)
}
assertTrue(result.isLeft(), "Expected Left (error) for compiled tx")
assertTrue(
result.leftOrNull() is GetFeeError.DataError,
"Expected GetFeeError.DataError wrapping IllegalStateException",
)
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* Unit tests for [CreateAndSendGaslessTransactionUseCase.getDestinationAddress] resolves the on-chain
* `to` of the user's main gasless sub-call.
*
* Regression guard: a yield-supply send must target the user's yield MODULE (the contract that
* runs `send(token, dest, amount)`), not the transfer recipient. Targeting the recipient reverts the whole
* batch with GAS_ESTIMATION_FAILED / require(false).
*/
internal class CreateAndSendGaslessDestinationAddressTest {
private val module = "0xmodule"
private val recipient = "0xrecipient"
private val tokenContract = "0xtokencontract"
private fun uncompiled(
destinationAddress: String,
extras: EthereumTransactionExtras?,
contractAddress: String?,
) = TransactionData.Uncompiled(
amount = mockk(relaxed = true),
fee = null,
sourceAddress = "0xsource",
destinationAddress = destinationAddress,
extras = extras,
contractAddress = contractAddress,
)
@Test
fun `GIVEN yield-supply send WHEN getDestinationAddress THEN returns module not recipient`() {
// Arrange — destinationAddress is patched to the yield module; the recipient lives inside the callData
val yieldCallData = EthereumYieldSupplySendCallData(
tokenContractAddress = tokenContract,
destinationAddress = recipient,
amount = mockk(relaxed = true),
)
val txData = uncompiled(
destinationAddress = module,
extras = EthereumTransactionExtras(callData = yieldCallData),
contractAddress = tokenContract,
)
// Act
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
// Assert
assertThat(to).isEqualTo(module)
}
@Test
fun `GIVEN ERC20 transfer WHEN getDestinationAddress THEN returns token contract`() {
// Arrange — a non-yield callData; `to` must be the token contract, not the recipient
val erc20CallData = object : SmartContractCallData {
override val methodId = "0xa9059cbb"
override val data = byteArrayOf(0x01)
override fun validate(blockchain: Blockchain) = true
}
val txData = uncompiled(
destinationAddress = recipient,
extras = EthereumTransactionExtras(callData = erc20CallData),
contractAddress = tokenContract,
)
// Act
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
// Assert
assertThat(to).isEqualTo(tokenContract)
}
@Test
fun `GIVEN non-yield tx without contract address WHEN getDestinationAddress THEN throws`() {
// Arrange
val txData = uncompiled(
destinationAddress = recipient,
extras = null,
contractAddress = null,
)
// Act & Assert
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
}
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessFeePlan
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase.GaslessPayload
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.math.BigInteger
/**
* Unit tests for [CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload].
* Pure function no coroutines or SDK side-effects.
*/
internal class CreateAndSendGaslessPayloadTest {
// ─── Common fixtures ─────────────────────────────────────────────────────────
private val mainTx = GaslessTransactionData.Transaction(
to = "0xmain",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x01, 0x02),
)
private val withdrawGasLimit = BigInteger.valueOf(150_000)
private val feeObj = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(21_000),
feeReceiver = "0xrecv",
)
private val nonce = BigInteger.valueOf(42)
// Minimal SmartContractCallData fake — only `data` is consumed by the SUT.
private val fakeWithdrawCallData = object : SmartContractCallData {
override val methodId: String = "0xfakeid"
override val data: ByteArray = byteArrayOf(0x12, 0x34)
override fun validate(blockchain: com.tangem.blockchain.common.Blockchain) = true
}
private val fakeToken: CryptoCurrency.Token = mockk(relaxed = true)
private val fakeTokenFee: Fee.Ethereum.TokenCurrency = mockk(relaxed = true)
private val fakeNativeFee: Fee = mockk(relaxed = true)
// ─── Case 1: TokenPayWithYieldWithdraw → GaslessPayload.Batch ────────────────
@Test
fun `TokenPayWithYieldWithdraw plan returns Batch with correct structure`() {
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = fakeToken,
fee = fakeTokenFee,
withdrawAmount = BigInteger.valueOf(7_000_001),
withdrawCallData = fakeWithdrawCallData,
yieldModuleAddress = "0xmodule",
)
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = withdrawGasLimit,
)
assertThat(result).isInstanceOf(GaslessPayload.Batch::class.java)
val batch = (result as GaslessPayload.Batch).data
// transactions list has exactly 2 entries
assertThat(batch.transactions).hasSize(2)
// index 0 is the unchanged main transaction
assertThat(batch.transactions[0]).isEqualTo(mainTx)
// index 1 is the yield-withdraw transaction
val withdrawTx = batch.transactions[1]
assertThat(withdrawTx.to).isEqualTo(plan.yieldModuleAddress)
assertThat(withdrawTx.value).isEqualTo(BigInteger.ZERO)
assertThat(withdrawTx.gasLimit).isEqualTo(withdrawGasLimit)
assertThat(withdrawTx.data).isEqualTo(fakeWithdrawCallData.data)
// fee and nonce are carried through
assertThat(batch.fee).isEqualTo(feeObj)
assertThat(batch.nonce).isEqualTo(nonce)
}
// ─── Case 2: TokenPay → GaslessPayload.Single ────────────────────────────────
@Test
fun `TokenPay plan returns Single wrapping mainTx feeObj and nonce`() {
val plan = GaslessFeePlan.TokenPay(feeToken = fakeToken, fee = fakeTokenFee)
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
val single = (result as GaslessPayload.Single).data
assertThat(single.transaction).isEqualTo(mainTx)
assertThat(single.fee).isEqualTo(feeObj)
assertThat(single.nonce).isEqualTo(nonce)
}
// ─── Case 3: null plan → GaslessPayload.Single (same as TokenPay) ───────────
@Test
fun `null plan returns Single wrapping mainTx feeObj and nonce`() {
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = null,
withdrawGasLimit = null,
)
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
val single = (result as GaslessPayload.Single).data
assertThat(single.transaction).isEqualTo(mainTx)
assertThat(single.fee).isEqualTo(feeObj)
assertThat(single.nonce).isEqualTo(nonce)
}
// ─── Case 4: NativePay → throws IllegalStateException ───────────────────────
@Test
fun `NativePay plan throws IllegalStateException`() {
val plan = GaslessFeePlan.NativePay(fee = fakeNativeFee)
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
}
}
// ─── Case 5: yield-withdraw plan without a withdraw gas limit → throws ────────
@Test
fun `TokenPayWithYieldWithdraw plan without withdrawGasLimit throws IllegalStateException`() {
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = fakeToken,
fee = fakeTokenFee,
withdrawAmount = BigInteger.valueOf(7_000_001),
withdrawCallData = fakeWithdrawCallData,
yieldModuleAddress = "0xmodule",
)
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
}
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONObject
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class Eip712TypedDataBuilderBatchTest {
@Test
fun `buildBatch emits GaslessBatchTransaction primary type with transactions array`() {
val tx = GaslessTransactionData.Transaction(
to = "0xaaa", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(0x12),
)
val withdraw = GaslessTransactionData.Transaction(
to = "0xbbb", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(0x34),
)
val fee = GaslessTransactionData.Fee(
feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv",
)
val batch = GaslessBatchTransactionData(listOf(tx, withdraw), fee, BigInteger.ZERO)
val json = JSONObject(Eip712TypedDataBuilder.buildBatch(batch, chainId = 1, verifyingContract = "0xuser"))
assertThat(json.getString("primaryType")).isEqualTo("GaslessBatchTransaction")
val message = json.getJSONObject("message")
assertThat(message.getJSONArray("transactions").length()).isEqualTo(2)
assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("to")).isEqualTo("0xbbb")
// v2: each sub-call carries its per-call gasLimit in the message
assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("gasLimit")).isEqualTo("150000")
val types = json.getJSONObject("types").getJSONArray("GaslessBatchTransaction")
assertThat(types.getJSONObject(0).getString("type")).isEqualTo("Transaction[]")
// v2: the Transaction struct adds gasLimit between value and data
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder()
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONObject
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class Eip712TypedDataBuilderTest {
@Test
fun `build emits GaslessTransaction primary type with per-call gasLimit in type and message`() {
// Arrange
val gaslessTransaction = GaslessTransactionData(
transaction = GaslessTransactionData.Transaction(
to = "0xaaa",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x12, 0x34),
),
fee = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(60_000),
feeReceiver = "0xrecv",
),
nonce = BigInteger.ZERO,
)
// Act
val json = JSONObject(
Eip712TypedDataBuilder.build(gaslessTransaction, chainId = 137, verifyingContract = "0xuser"),
)
// Assert
assertThat(json.getString("primaryType")).isEqualTo("GaslessTransaction")
// v2: the single transaction carries its per-call gasLimit in the message
val txMessage = json.getJSONObject("message").getJSONObject("transaction")
assertThat(txMessage.getString("gasLimit")).isEqualTo("120000")
// v2: the Transaction struct adds gasLimit between value and data (order defines the EIP-712 typehash)
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder()
// Domain is unchanged between v1/v2; verifyingContract is the user's EOA address
val domain = json.getJSONObject("domain")
assertThat(domain.getString("name")).isEqualTo("Tangem7702GaslessExecutor")
assertThat(domain.getString("version")).isEqualTo("1")
assertThat(domain.getString("verifyingContract")).isEqualTo("0xuser")
}
@Test
fun `build with includeGasLimit false omits gasLimit reproducing the v1 typehash`() {
// Arrange
val gaslessTransaction = GaslessTransactionData(
transaction = GaslessTransactionData.Transaction(
to = "0xaaa",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x12, 0x34),
),
fee = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(60_000),
feeReceiver = "0xrecv",
),
nonce = BigInteger.ZERO,
)
// Act — v1 mode (feature flag off)
val json = JSONObject(
Eip712TypedDataBuilder.build(
gaslessTransaction = gaslessTransaction,
chainId = 137,
verifyingContract = "0xuser",
includeGasLimit = false,
),
)
// Assert: the Transaction struct is the legacy {to, value, data} — gasLimit drives the typehash, so its
// absence reproduces exactly the v1 hash the current develop signs.
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "data").inOrder()
// and the message carries no gasLimit
val txMessage = json.getJSONObject("message").getJSONObject("transaction")
assertThat(txMessage.has("gasLimit")).isFalse()
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase.Companion.isEligibleFeeToken
import com.tangem.test.core.ProvideTestModels
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetAvailableFeeTokensUseCaseTest {
@ParameterizedTest
@ProvideTestModels
fun isEligible(model: EligibilityModel) {
// Arrange
val status = createStatus(model.yieldSupplyStatus)
// Act
val actual = isEligibleFeeToken(status, isYieldWithdrawEnabled = model.isYieldWithdrawEnabled)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
// Plain token (no yield status) is always eligible, regardless of the toggle.
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = false, expected = true),
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = true, expected = true),
// Active yield: eligible only when gasless v2 (yield withdraw) is enabled.
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = true),
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
// Inactive yield status: excluded either way (no module to withdraw from).
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = false),
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
)
internal data class EligibilityModel(
val yieldSupplyStatus: YieldSupplyStatus?,
val isYieldWithdrawEnabled: Boolean,
val expected: Boolean,
)
private fun createStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
val status = mockk<CryptoCurrencyStatus>()
every { status.value.yieldSupplyStatus } returns yieldSupplyStatus
return status
}
private companion object {
val ACTIVE_YIELD = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val INACTIVE_YIELD = ACTIVE_YIELD.copy(isActive = false)
}
}

View file

@ -0,0 +1,425 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
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.models.wallet.UserWalletId
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.GaslessFeePlan
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
/**
* Unit tests for [ResolveGaslessFeePlanUseCase].
* Covers every branch of the gasless fee decision tree.
*/
internal class ResolveGaslessFeePlanUseCaseTest {
private lateinit var gaslessYieldRepository: GaslessYieldRepository
private lateinit var useCase: ResolveGaslessFeePlanUseCase
private val mockUserWalletId: UserWalletId = mockk(relaxed = true)
private val mockUserWallet: UserWallet = mockk<UserWallet.Hot>().also {
every { it.walletId } returns mockUserWalletId
}
@BeforeEach
fun setup() {
gaslessYieldRepository = mockk()
useCase = ResolveGaslessFeePlanUseCase(gaslessYieldRepository)
}
// ─── Case 1: plain balance >= required → TokenPay ──────────────────────────
@Test
fun `plain balance covers fee returns TokenPay`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull()
assertThat(plan).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
assertThat((plan as GaslessFeePlan.TokenPay).fee).isEqualTo(tokenFee)
}
@Test
fun `plain balance equals required returns TokenPay`() = runTest {
val amount = BigDecimal("5")
val tokenStatus = tokenStatus(plainBalance = amount, decimals = 6)
val tokenFee = tokenFee(feeAmount = amount, decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
}
// ─── Case 2: yield-active with no liquid → the whole fee is withdrawn from the module ──
@Test
fun `yield active with no liquid withdraws the whole fee`() = runTest {
val decimals = 6
// value.amount is effectiveBalance = liquid(EOA) + effectiveProtocolBalance. Here total == module
// balance (20), so liquid is 0 and the entire fee must be withdrawn from the module — the plan must
// not short-circuit to TokenPay.
// withdraw == feeAmount, CEILING-rounded: 10000000.5 → 10000001 (floor would give 10000000).
val feeAmount = BigDecimal("10.0000005")
val moduleBalance = BigDecimal("20")
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger()
val floorAmount = feeAmount.movePointRight(decimals).toBigInteger() // 10000000
assertThat(expectedWithdrawAmount).isGreaterThan(floorAmount)
// value.amount == module balance → liquid is 0, so the fee cannot be paid from the EOA (no TokenPay).
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = mockUserWalletId,
cryptoCurrency = any(),
amount = any(),
)
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
// Must be 10000001 (CEILING of the fee), not the module balance and not floor.
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(10_000_001))
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
}
// ─── Case 2b: send amount counts toward sufficiency but NOT toward the withdraw ────────────
@Test
fun `yield active withdraw covers only the fee not the send amount`() = runTest {
val decimals = 6
// The main module.send tx moves the send amount from the module itself, so the fee-withdraw must
// cover ONLY the fee. Including the send amount would withdraw it twice and overdraw the module.
val feeAmount = BigDecimal("3.0")
val sendAmountInFeeToken = BigDecimal("1.5")
val moduleBalance = BigDecimal("5.0") // covers required = fee(3.0) + send(1.5) = 4.5 ✓
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger() // 3000000 — the FEE only, NOT 4.5
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = mockUserWalletId,
cryptoCurrency = any(),
amount = any(),
)
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = sendAmountInFeeToken,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(3_000_000))
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
}
// ─── Case 2c: module cannot cover send + fee → NotEnoughFunds ──────────────
@Test
fun `yield active module cannot cover send plus fee returns NotEnoughFunds`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("4"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("3"), decimals = 6)
// required = fee(3) + send(1.5) = 4.5, but the module holds only 4.0
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("4.0")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal("1.5"),
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 3: plain insufficient, isYieldActive=false → NotEnoughFunds ──────
@Test
fun `plain insufficient yield inactive returns NotEnoughFunds`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("1"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 4: YieldModuleUpgradeUnavailableException → ModuleUpdateUnavailable
@Test
fun `createPartialWithdrawCallData throws UpgradeUnavailableException returns ModuleUpdateUnavailable`() = runTest {
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("10")
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
} throws YieldModuleUpgradeUnavailableException("0xold")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
}
// ─── Case 5: plain + yield < required → NotEnoughFunds ─────────────────────
@Test
fun `plain plus yield insufficient returns NotEnoughFunds`() = runTest {
// total(6) = liquid(1) + module(5) < fee(10) → not enough funds anywhere.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("6"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("10"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("5") // liquid 1 + module 5 = 6 < 10
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 6: YieldModuleVersionIndeterminateException → ModuleUpdateUnavailable
@Test
fun `createPartialWithdrawCallData throws VersionIndeterminateException returns ModuleUpdateUnavailable`() = runTest {
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("10")
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
} throws YieldModuleVersionIndeterminateException("rpc error")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
}
// ─── Case 7: liquid EOA balance covers most of send+fee, protocol alone does not ──────────────
@Test
fun `GIVEN liquid covers send but protocol alone does not WHEN yield active THEN TokenPayWithYieldWithdraw`() =
runTest {
// value.amount is effectiveBalance (liquid EOA + effectiveProtocolBalance). The user sends 3.00 of
// 3.585624 total. The yield module (effectiveProtocolBalance) holds only 0.6, the rest (2.985624)
// is liquid on the EOA. required = send(3.00) + fee(0.05) = 3.05 < total(3.585624), so funds ARE
// sufficient. The old check compared the module balance (0.6) against required and wrongly raised
// NotEnoughFunds.
val decimals = 6
val totalBalance = BigDecimal("3.585624")
val moduleBalance = BigDecimal("0.6")
val feeAmount = BigDecimal("0.05")
val sendAmount = BigDecimal("3.00")
// module.send consumes EOA liquid first, leaving 0 for the fee, so the whole fee must be withdrawn.
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger()
val tokenStatus = tokenStatus(plainBalance = totalBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
// Act
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = sendAmount,
)
// Assert
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
}
// ─── Case 8: liquid EOA balance alone covers send + fee → no withdraw needed ───────────────────
@Test
fun `GIVEN liquid covers send plus fee WHEN yield active THEN TokenPay without withdraw`() = runTest {
// Arrange — liquid = total(10) - module(2) = 8, which already covers required = send(3) + fee(1) = 4.
// The EOA holds enough after the main send to settle the fee, so no yield withdraw is needed.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("1"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("2")
// Act
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal("3"),
)
// Assert
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
}
// ─── Helpers ────────────────────────────────────────────────────────────────
private fun tokenStatus(
plainBalance: BigDecimal = BigDecimal("100"),
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
val status = mockk<CryptoCurrencyStatus>()
every { status.currency } returns token
every { status.value.amount } returns plainBalance
return status
}
private fun tokenFee(feeAmount: BigDecimal, decimals: Int = 6): Fee.Ethereum.TokenCurrency {
val blockchainToken = Token(symbol = "USDC", contractAddress = "0xUSDC", decimals = decimals)
val amount = Amount(token = blockchainToken, value = feeAmount)
return Fee.Ethereum.TokenCurrency(
amount = amount,
gasLimit = BigInteger("100000"),
coinPriceInToken = BigInteger("2000000000"),
feeTransferGasLimit = BigInteger("60000"),
baseGas = BigInteger("21000"),
)
}
}

View file

@ -14,7 +14,10 @@ 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.models.yield.supply.YieldSupplyStatus
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.coEvery
@ -36,6 +39,7 @@ class TokenFeeCalculatorTest {
private lateinit var walletManagersFacade: WalletManagersFacade
private lateinit var gaslessTransactionRepository: GaslessTransactionRepository
private lateinit var gaslessYieldRepository: GaslessYieldRepository
private lateinit var demoConfig: DemoConfig
private lateinit var tokenFeeCalculator: TokenFeeCalculator
@ -49,12 +53,14 @@ class TokenFeeCalculatorTest {
fun setup() {
walletManagersFacade = mockk()
gaslessTransactionRepository = mockk()
gaslessYieldRepository = mockk()
demoConfig = mockk()
tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
mockWalletManager = mockk()
@ -215,6 +221,9 @@ class TokenFeeCalculatorTest {
assertNotNull(feeExtended)
assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId)
assertTrue(feeExtended.transactionFee is TransactionFee.Single)
// main-tx per-call gas = initialFee.gasLimit; no withdraw on the non-yield path
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertNull(feeExtended.withdrawGasLimit)
}
}
@ -413,6 +422,283 @@ class TokenFeeCalculatorTest {
}
}
// ===== Yield-path Tests =====
/**
* With active yield, a token whose plain balance is small (not enough to pay the fee on its own) must NOT
* raise NotEnoughFunds the resolver decides coverage. The gas limit must include the extra withdraw gas.
*
* Here `userWallet` is not passed (null), so the withdraw gas estimation is skipped and the
* deterministic fallback [WITHDRAW_GAS_LIMIT] is used.
*
* Expected gasLimit breakdown (matching companion constants):
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
* baseGas = 21_000
* WITHDRAW_GAS_LIMIT = 150_000
* total = 337_000
*/
@Test
fun `calculateTokenFee with active yield but no wallet falls back to WITHDRAW_GAS_LIMIT`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"), // yield covers the rest
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"), // tiny plain balance — insufficient on its own
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
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,
isYieldActive = true,
)
// Then
assertTrue(result.isRight(), "Expected success on yield path with small plain balance")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 = 337_000
assertEquals(BigInteger("337000"), fee.gasLimit, "gasLimit must include WITHDRAW_GAS_LIMIT (150000)")
// feeTransferGasLimit stored in the fee object = 66_000
assertEquals(BigInteger("66000"), fee.feeTransferGasLimit, "feeTransferGasLimit = 60000 * 1.10")
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = WITHDRAW_GAS_LIMIT
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
}
}
/**
* With active yield, when getGasLimit reverts due to zero plain balance
* (BlockchainSdkError.Ethereum.InsufficientFundsForOperation wrapped in WrappedThrowable),
* calculateTokenFee must use the deterministic FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) instead of raising.
*
* Expected breakdown:
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 100_000 * 1.10 = 110_000 (FALLBACK_FEE_TRANSFER_GAS_LIMIT * 1.10)
* baseGas = 21_000
* WITHDRAW_GAS_LIMIT = 150_000
* total gasLimit = 381_000
*/
@Test
fun `calculateTokenFee with active yield uses fallback gas when transfer estimation reverts with insufficient funds`() =
runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
// Zero plain balance — exactly the condition that causes estimation revert
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
// Simulate on-chain estimation reverting with InsufficientFundsForOperation
val insufficientFundsException =
BlockchainSdkError.Ethereum.InsufficientFundsForOperation("insufficient funds for gas")
val wrappedError = BlockchainSdkError.WrappedThrowable(insufficientFundsException)
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Failure(wrappedError)
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
)
// Then
assertTrue(result.isRight(), "Expected success with fallback gas on yield path")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// feeTransferGasLimit = FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) * 1.10 = 110_000
assertEquals(
BigInteger("110000"),
fee.feeTransferGasLimit,
"feeTransferGasLimit must use fallback (100000 * 1.10 = 110000)",
)
// gasLimit = 100_000 + 110_000 + 21_000 + 150_000 = 381_000
assertEquals(
BigInteger("381000"),
fee.gasLimit,
"gasLimit must include WITHDRAW_GAS_LIMIT (150000)",
)
}
}
/**
* Confirms that the non-yield path (isYieldActive = false, default) is unchanged:
* a token with insufficient plain balance still raises NotEnoughFunds.
*/
@Test
fun `calculateTokenFee without yield still raises NotEnoughFunds on insufficient balance`() = runTest {
// Given
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"), // very small — insufficient
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 — default isYieldActive = false
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isLeft(), "Non-yield path must still raise NotEnoughFunds for insufficient balance")
result.onLeft { error ->
assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds)
}
}
/**
* With active yield AND a wallet, the withdraw gas limit is estimated on-chain via a probe
* `withdraw(yieldToken, 10000)` against the yield module. The estimated value (here 200_000) flows into
* BOTH the maxTokenFee cap and the signed per-call withdraw gas limit not the hardcoded fallback.
*
* Expected gasLimit breakdown:
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
* baseGas = 21_000
* estimated withdraw = 200_000
* total = 387_000
*/
@Test
fun `calculateTokenFee with active yield and wallet estimates withdraw gas on-chain`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// fee-transfer estimation (to the fee receiver) vs. withdraw estimation (to the yield module)
coEvery {
mockWalletManager.getGasLimit(any(), "0xFeeReceiver", any())
} returns Result.Success(BigInteger("60000"))
coEvery {
mockWalletManager.getGasLimit(any(), "0xModule", any())
} returns Result.Success(BigInteger("200000"))
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xModule"
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
} returns mockk<SmartContractCallData>(relaxed = true)
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
userWallet = mockUserWallet,
)
// Then
assertTrue(result.isRight(), "Expected success on yield path with on-chain withdraw estimation")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// gasLimit = 100_000 + 66_000 + 21_000 + 200_000 = 387_000
assertEquals(BigInteger("387000"), fee.gasLimit, "gasLimit must include the estimated withdraw gas")
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = estimated 200_000
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertEquals(BigInteger("200000"), feeExtended.withdrawGasLimit)
}
coVerify { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) }
coVerify { mockWalletManager.getGasLimit(any(), "0xModule", any()) }
}
/**
* When the yield module address is unavailable (e.g. module not yet deployed), the on-chain estimation
* is skipped and the calculator falls back to [WITHDRAW_GAS_LIMIT] even though a wallet is provided.
*/
@Test
fun `calculateTokenFee with active yield falls back when yield module address is unavailable`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
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")
coEvery { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) } returns null
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
userWallet = mockUserWallet,
)
// Then
assertTrue(result.isRight())
result.onRight { feeExtended ->
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 (fallback) = 337_000
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
assertEquals(BigInteger("337000"), fee.gasLimit)
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
}
// withdraw estimation must NOT be attempted without a module address
coVerify(exactly = 0) { gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) }
}
// ===== Helper Methods =====
private fun createMockTransactionFee(): TransactionFee {
@ -455,6 +741,21 @@ class TokenFeeCalculatorTest {
return status
}
/**
* Returns a copy of this [CryptoCurrencyStatus] mock with [yieldSupplyStatus] overridden.
* Since [CryptoCurrencyStatus] is a mockk, we create a new mock that delegates everything and
* overrides only [yieldSupplyStatus].
*/
private fun CryptoCurrencyStatus.withYieldSupplyStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
val original = this
val newStatus = mockk<CryptoCurrencyStatus>()
every { newStatus.currency } returns original.currency
every { newStatus.value.amount } returns original.value.amount
every { newStatus.value.fiatRate } returns original.value.fiatRate
every { newStatus.value.yieldSupplyStatus } returns yieldSupplyStatus
return newStatus
}
private fun createMockNativeCurrencyStatus(
fiatRate: BigDecimal? = BigDecimal("2000"),
decimals: Int = 18,
@ -471,4 +772,4 @@ class TokenFeeCalculatorTest {
return status
}
}
}