Updated on 2026-08-14
This commit is contained in:
parent
7e94a484a0
commit
80e33b892a
51 changed files with 2975 additions and 445 deletions
|
|
@ -251,5 +251,15 @@
|
|||
"info": "GaslessTransactions",
|
||||
"source": "https://github.com/tangem-developments/tangem-gasless-service",
|
||||
"name": "gaslessTransaction"
|
||||
},
|
||||
"0x4b072692": {
|
||||
"info": "GaslessTransactions",
|
||||
"source": "https://github.com/tangem-developments/tangem-gasless-service",
|
||||
"name": "gaslessTransaction"
|
||||
},
|
||||
"0xf9b181bf": {
|
||||
"info": "GaslessTransactions",
|
||||
"source": "https://github.com/tangem-developments/tangem-gasless-service",
|
||||
"name": "gaslessTransaction"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Guards the `contract_methods.json` asset consumed by `SdkTransactionTypeConverter` (via
|
||||
* `DefaultWalletManagersFacade.readSmartContractMethods`). History marking of gasless fee transfers
|
||||
* relies on every gasless entry-point selector being mapped to the `gaslessTransaction` method name.
|
||||
*/
|
||||
internal class ContractMethodsAssetTest {
|
||||
|
||||
private val methods: Map<String, Map<String, String>> by lazy {
|
||||
val json = File("src/main/assets/contract_methods.json").readText()
|
||||
val type = Types.newParameterizedType(
|
||||
Map::class.java,
|
||||
String::class.java,
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java),
|
||||
)
|
||||
requireNotNull(Moshi.Builder().build().adapter<Map<String, Map<String, String>>>(type).fromJson(json))
|
||||
}
|
||||
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["0x6234d42b", "0x4b072692", "0xf9b181bf"])
|
||||
fun `GIVEN gasless selector WHEN asset parsed THEN maps to gaslessTransaction`(selector: String) {
|
||||
val entry = methods[selector]
|
||||
|
||||
assertThat(entry).isNotNull()
|
||||
assertThat(entry?.get("name")).isEqualTo("gaslessTransaction")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,14 @@ package com.tangem.domain.yield.supply
|
|||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface YieldSupplyTransactionRepository {
|
||||
interface YieldSupplyTransactionRepository : GaslessYieldRepository {
|
||||
|
||||
suspend fun createEnterTransactions(
|
||||
userWalletId: UserWalletId,
|
||||
|
|
@ -23,10 +24,6 @@ interface YieldSupplyTransactionRepository {
|
|||
fee: Fee?,
|
||||
): TransactionData.Uncompiled
|
||||
|
||||
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
|
||||
|
||||
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
|
||||
|
||||
/**
|
||||
* Checks the version status of the user's yield-module contract and wraps [callData] with an
|
||||
* upgrade transaction if the deployed version is out of date.
|
||||
|
|
@ -36,4 +33,7 @@ interface YieldSupplyTransactionRepository {
|
|||
network: Network,
|
||||
callData: SmartContractCallData,
|
||||
): SmartContractCallData
|
||||
|
||||
/** Returns the on-chain version status of the user's yield module for [network]. */
|
||||
suspend fun getYieldModuleVersionStatus(userWalletId: UserWalletId, network: Network): YieldModuleVersionStatus
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue