Updated on 2026-08-14
This commit is contained in:
commit
45156f05b0
1184 changed files with 47887 additions and 11982 deletions
|
|
@ -36,4 +36,9 @@ dependencies {
|
|||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.card)
|
||||
api(projects.domain.networks)
|
||||
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
}
|
||||
|
|
@ -9,4 +9,14 @@ sealed class GetFeeError {
|
|||
data object KaspaZeroUtxo : BlockchainErrors()
|
||||
data object SuiOneCoinRequired : BlockchainErrors()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gasless transaction related errors, model logic uses this errors types, don't remove or change them
|
||||
*/
|
||||
sealed class GaslessError : GetFeeError() {
|
||||
data object NetworkIsNotSupported : GaslessError()
|
||||
data object NoSupportedTokensFound : GaslessError()
|
||||
data object NotEnoughFunds : GaslessError()
|
||||
data class DataError(val cause: Throwable?) : GaslessError()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.domain.transaction
|
||||
|
||||
import arrow.core.raise.Raise
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
|
||||
fun Raise<GetFeeError>.raiseIllegalStateError(error: String): Nothing {
|
||||
raise(GetFeeError.DataError(IllegalStateException(error)))
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.tangem.domain.transaction
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import java.math.BigInteger
|
||||
|
||||
interface GaslessTransactionRepository {
|
||||
|
||||
suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency>
|
||||
|
||||
suspend fun getTokenFeeReceiverAddress(): String
|
||||
|
||||
/**
|
||||
* Sends gasless transaction to the gasless service for signing and returns the signed result.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User signs the gasless transaction data locally with their private key
|
||||
* 2. This method submits the transaction + user signature to the gasless service
|
||||
* 3. Service validates the signature and transaction data
|
||||
* 4. Service adds its own signature for fee delegation (pays gas in tokens)
|
||||
* 5. Service constructs the final EIP-1559 transaction with all signatures
|
||||
* 6. Service returns the fully signed transaction ready for broadcasting
|
||||
*
|
||||
* The gasless service acts as a relayer that:
|
||||
* - Pays network gas fees on behalf of the user
|
||||
* - Receives payment in the specified token from the user's wallet
|
||||
* - Ensures atomic execution (either both transfers succeed or both fail)
|
||||
*
|
||||
* @param gaslessTransactionData domain model containing:
|
||||
* - transaction: target contract call data (to, value, data)
|
||||
* - fee: token payment configuration (feeToken, maxTokenFee, etc.)
|
||||
* - nonce: user's contract nonce to prevent replay attacks
|
||||
* @param signature user's ECDSA signature of the gasless transaction in hex format (0x...)
|
||||
* Signs keccak256 hash of the transaction data
|
||||
* @param userAddress user's Ethereum address (EOA or contract wallet)
|
||||
* @param network blockchain network (Ethereum, Polygon, BSC, etc.)
|
||||
* Used to determine chainId for the request
|
||||
* @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract
|
||||
* Required only when user's EOA needs to temporarily act as contract wallet
|
||||
* Contains signature authorizing delegation to entry point contract
|
||||
* @return [GaslessSignedTransactionResult] containing:
|
||||
* - signedTransaction: complete RLP-encoded transaction ready to broadcast
|
||||
* - gasLimit: actual gas limit allocated by the service
|
||||
* - maxFeePerGas: maximum fee per gas (base + priority) in wei
|
||||
* - maxPriorityFeePerGas: tip for validators in wei (EIP-1559)
|
||||
* @throws IllegalStateException if network is not supported or chainId cannot be determined
|
||||
* @throws Exception if service returns error or network request fails
|
||||
*/
|
||||
suspend fun signGaslessTransaction(
|
||||
gaslessTransactionData: GaslessTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization? = null,
|
||||
): GaslessSignedTransactionResult
|
||||
|
||||
/**
|
||||
* Hardcoded value as baseGas
|
||||
*/
|
||||
fun getBaseGasForTransaction(): BigInteger
|
||||
|
||||
fun getChainIdForNetwork(network: Network): Int
|
||||
|
||||
suspend fun getGaslessFeeAddresses(): Set<String>
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.domain.transaction.models
|
||||
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* EIP-7702 authorization for account abstraction.
|
||||
* Used for delegating EOA (Externally Owned Account) control to a smart contract.
|
||||
*
|
||||
* EIP-7702 allows EOAs to temporarily act as smart contract wallets by delegating
|
||||
* their authority to a contract address for a specific transaction.
|
||||
*
|
||||
* @property chainId blockchain network chain ID
|
||||
* @property address contract address to delegate authority to (entry point contract)
|
||||
* @property nonce authorization nonce to prevent replay attacks
|
||||
* @property yParity recovery ID for signature (0 or 1)
|
||||
* @property r ECDSA signature component R
|
||||
* @property s ECDSA signature component S
|
||||
*
|
||||
* @see <a href="https://eips.ethereum.org/EIPS/eip-7702">EIP-7702 Specification</a>
|
||||
*/
|
||||
data class Eip7702Authorization(
|
||||
val chainId: Int,
|
||||
val address: String,
|
||||
val nonce: BigInteger,
|
||||
val yParity: Int,
|
||||
val r: String,
|
||||
val s: String,
|
||||
) {
|
||||
init {
|
||||
require(chainId > 0) { "Chain ID must be positive" }
|
||||
require(address.isNotBlank()) { "Address must not be blank" }
|
||||
require(yParity in 0..1) { "yParity must be 0 or 1" }
|
||||
require(r.isNotBlank()) { "Signature component R must not be blank" }
|
||||
require(s.isNotBlank()) { "Signature component S must not be blank" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.domain.transaction.models
|
||||
|
||||
/**
|
||||
* Result of gasless transaction signing from the gasless service.
|
||||
* Contains the fully signed transaction ready for broadcasting and gas parameters.
|
||||
*
|
||||
* After the user signs the gasless transaction data locally, the service:
|
||||
* 1. Validates the signature
|
||||
* 2. Adds its own signature for fee delegation
|
||||
* 3. Constructs the final transaction
|
||||
* 4. Returns this signed transaction with gas parameters
|
||||
*
|
||||
* @property txHash sent tx hash
|
||||
*/
|
||||
data class GaslessSignedTransactionResult(
|
||||
val txHash: String,
|
||||
) {
|
||||
init {
|
||||
require(txHash.isNotBlank()) { "Tx hash must not be blank" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.tangem.domain.transaction.models
|
||||
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Domain model for gasless transaction.
|
||||
* Represents complete transaction data with fee delegation metadata.
|
||||
*/
|
||||
data class GaslessTransactionData(
|
||||
/** Transaction details */
|
||||
val transaction: Transaction,
|
||||
/** Fee payment configuration */
|
||||
val fee: Fee,
|
||||
/** Nonce from user's contract */
|
||||
val nonce: BigInteger,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Core transaction data.
|
||||
*
|
||||
* @property to destination address
|
||||
* @property value transaction value in wei (currently always 0 for gasless)
|
||||
* @property data encoded transaction data (contract call)
|
||||
*/
|
||||
data class Transaction(
|
||||
val to: String,
|
||||
val value: BigInteger,
|
||||
val data: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Transaction
|
||||
|
||||
if (to != other.to) return false
|
||||
if (value != other.value) return false
|
||||
if (!data.contentEquals(other.data)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = to.hashCode()
|
||||
result = 31 * result + value.hashCode()
|
||||
result = 31 * result + data.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fee configuration for gasless transaction.
|
||||
*
|
||||
* @property feeToken token address used for fee payment
|
||||
* @property maxTokenFee maximum fee amount in fee token units
|
||||
* @property coinPriceInToken price of native coin in fee token units
|
||||
* @property feeTransferGasLimit gas limit for fee token transfer
|
||||
* @property baseGas base gas cost (currently constant BASE_GAS, may vary in future)
|
||||
* @property feeReceiver address receiving the fee payment
|
||||
*/
|
||||
data class Fee(
|
||||
val feeToken: String,
|
||||
val maxTokenFee: BigInteger,
|
||||
val coinPriceInToken: BigInteger,
|
||||
val feeTransferGasLimit: BigInteger,
|
||||
val baseGas: BigInteger,
|
||||
val feeReceiver: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.domain.transaction.models
|
||||
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
|
||||
data class TransactionFeeExtended(
|
||||
val transactionFee: TransactionFee,
|
||||
val feeTokenId: CryptoCurrency.ID,
|
||||
)
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||
import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider
|
||||
import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData
|
||||
import com.tangem.blockchain.common.*
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.blockchain.extensions.formatHex
|
||||
import com.tangem.blockchain.extensions.normalizeByteArray
|
||||
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
|
||||
import com.tangem.common.CompletionResult
|
||||
import com.tangem.common.extensions.toDecompressedPublicKey
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
|
||||
import com.tangem.domain.card.models.TwinKey
|
||||
import com.tangem.domain.card.repository.CardSdkConfigRepository
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import java.math.BigInteger
|
||||
|
||||
class CreateAndSendGaslessTransactionUseCase(
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
private val cardSdkConfigRepository: CardSdkConfigRepository,
|
||||
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
transactionData: TransactionData,
|
||||
fee: TransactionFeeExtended,
|
||||
): Either<SendTransactionError, String> = either {
|
||||
catch(
|
||||
block = {
|
||||
val uncompiledTxData = validateTransactionData(transactionData)
|
||||
val context = prepareGaslessContext(userWallet, uncompiledTxData, fee)
|
||||
val signedData = signGaslessTransactionByUser(userWallet, context, uncompiledTxData)
|
||||
signAndSendTransactionOnBackend(context, signedData, uncompiledTxData)
|
||||
},
|
||||
catch = {
|
||||
raise(SendTransactionError.DataError(it.message))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and casts transaction data to Uncompiled type.
|
||||
*/
|
||||
private fun validateTransactionData(transactionData: TransactionData): TransactionData.Uncompiled {
|
||||
return transactionData as? TransactionData.Uncompiled
|
||||
?: error("Uncompiled transaction data required")
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares all necessary context for gasless transaction.
|
||||
* Includes: wallet manager, gasless provider, token status, nonce, transaction data.
|
||||
*/
|
||||
private suspend fun prepareGaslessContext(
|
||||
userWallet: UserWallet,
|
||||
transactionData: TransactionData.Uncompiled,
|
||||
fee: TransactionFeeExtended,
|
||||
): GaslessContext {
|
||||
val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
|
||||
userWallet.walletId,
|
||||
fee.feeTokenId,
|
||||
).getOrNull() ?: error("Token for fee not found")
|
||||
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWallet.walletId,
|
||||
tokenForFeeStatus.currency.network,
|
||||
) ?: error("WalletManager not found for network ${tokenForFeeStatus.currency.network.id}")
|
||||
|
||||
val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error(
|
||||
"WalletManager for network ${tokenForFeeStatus.currency.network.id} " +
|
||||
"does not support gasless transactions",
|
||||
)
|
||||
|
||||
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
|
||||
|
||||
val gaslessTransactionData = createGaslessTransactionData(
|
||||
transactionData = transactionData,
|
||||
txFee = fee,
|
||||
tokenFeeStatus = tokenForFeeStatus,
|
||||
nonce = gaslessContractNonce,
|
||||
)
|
||||
|
||||
val chainId = gaslessTransactionRepository.getChainIdForNetwork(tokenForFeeStatus.currency.network)
|
||||
|
||||
return GaslessContext(
|
||||
walletManager = walletManager,
|
||||
gaslessDataProvider = gaslessDataProvider,
|
||||
tokenForFeeStatus = tokenForFeeStatus,
|
||||
gaslessTransactionData = gaslessTransactionData,
|
||||
chainId = chainId,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets contract nonce with fallback to zero on failure.
|
||||
*/
|
||||
private suspend fun getContractNonce(
|
||||
gaslessDataProvider: EthereumGaslessDataProvider,
|
||||
userAddress: String,
|
||||
): BigInteger {
|
||||
return when (val nonceResult = gaslessDataProvider.getGaslessContractNonce(userAddress)) {
|
||||
is Result.Failure -> BigInteger.ZERO
|
||||
is Result.Success -> nonceResult.data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs gasless transaction and EIP-7702 authorization.
|
||||
* Returns prepared signatures and authorization data.
|
||||
*/
|
||||
private suspend fun signGaslessTransactionByUser(
|
||||
userWallet: UserWallet,
|
||||
context: GaslessContext,
|
||||
transactionData: TransactionData.Uncompiled,
|
||||
): SignedGaslessData {
|
||||
val eip712Data = Eip712TypedDataBuilder.build(
|
||||
gaslessTransaction = context.gaslessTransactionData,
|
||||
chainId = context.chainId,
|
||||
verifyingContract = transactionData.sourceAddress,
|
||||
)
|
||||
|
||||
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
|
||||
val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider)
|
||||
|
||||
val signedHashes = signHashes(
|
||||
userWallet = userWallet,
|
||||
walletManager = context.walletManager,
|
||||
hashesToOperate = HashesToOperate(
|
||||
eip712Hash = eip712HashToSign,
|
||||
eip7702Hash = eip7702Data.data,
|
||||
),
|
||||
)
|
||||
|
||||
val decompressedPublicKey = context.walletManager
|
||||
.wallet.publicKey.blockchainKey
|
||||
.toDecompressedPublicKey()
|
||||
|
||||
val preparedEip712Hash = UnmarshalHelper.unmarshalSignatureExtended(
|
||||
signature = signedHashes.eip712Hash,
|
||||
hash = eip712HashToSign,
|
||||
publicKey = decompressedPublicKey,
|
||||
).asRSVLegacyEVM().toHexString().formatHex().lowercase()
|
||||
|
||||
val extendedEip7702Data = UnmarshalHelper.unmarshalSignatureExtended(
|
||||
signature = signedHashes.eip7702Hash,
|
||||
hash = eip7702Data.data,
|
||||
publicKey = decompressedPublicKey,
|
||||
)
|
||||
|
||||
val eip7702Auth = Eip7702Authorization(
|
||||
chainId = context.chainId,
|
||||
address = eip7702Data.executorAddress,
|
||||
nonce = eip7702Data.nonce,
|
||||
yParity = extendedEip7702Data.recId,
|
||||
r = extendedEip7702Data.r.toFormattedHex(bytes = 32),
|
||||
s = extendedEip7702Data.s.toFormattedHex(bytes = 32),
|
||||
)
|
||||
|
||||
return SignedGaslessData(
|
||||
eip712Signature = preparedEip712Hash,
|
||||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends gasless transaction to the service.
|
||||
*/
|
||||
private suspend fun signAndSendTransactionOnBackend(
|
||||
context: GaslessContext,
|
||||
signedData: SignedGaslessData,
|
||||
transactionData: TransactionData.Uncompiled,
|
||||
): String {
|
||||
val txHash = gaslessTransactionRepository.signGaslessTransaction(
|
||||
network = context.tokenForFeeStatus.currency.network,
|
||||
gaslessTransactionData = context.gaslessTransactionData,
|
||||
signature = signedData.eip712Signature,
|
||||
userAddress = transactionData.sourceAddress,
|
||||
eip7702Auth = signedData.eip7702Auth,
|
||||
).txHash
|
||||
|
||||
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
|
||||
transactionData = transactionData,
|
||||
txHash = txHash,
|
||||
contractAddress = transactionData.contractAddress,
|
||||
)
|
||||
|
||||
return txHash
|
||||
}
|
||||
|
||||
private suspend fun signHashes(
|
||||
userWallet: UserWallet,
|
||||
walletManager: WalletManager,
|
||||
hashesToOperate: HashesToOperate,
|
||||
): HashesToOperate {
|
||||
val signer = getSigner(userWallet)
|
||||
val hashesToSign = listOf(hashesToOperate.eip712Hash, hashesToOperate.eip7702Hash)
|
||||
|
||||
return when (val signerResult = signer.sign(hashesToSign, walletManager.wallet.publicKey)) {
|
||||
is CompletionResult.Failure -> error("Signing failed: ${signerResult.error.message ?: "sdk error"}")
|
||||
is CompletionResult.Success -> {
|
||||
val signatures = signerResult.data
|
||||
require(signatures.size == 2) { "Expected 2 signatures, got ${signatures.size}" }
|
||||
HashesToOperate(
|
||||
eip712Hash = signatures[0],
|
||||
eip7702Hash = signatures[1],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSigner(userWallet: UserWallet): TransactionSigner {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Cold -> {
|
||||
val card = userWallet.scanResponse.card
|
||||
val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins
|
||||
|
||||
cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
)
|
||||
}
|
||||
is UserWallet.Hot -> getHotWalletSigner(userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createGaslessTransactionData(
|
||||
transactionData: TransactionData.Uncompiled,
|
||||
txFee: TransactionFeeExtended,
|
||||
tokenFeeStatus: CryptoCurrencyStatus,
|
||||
nonce: BigInteger,
|
||||
): GaslessTransactionData {
|
||||
val transaction = buildTransaction(transactionData)
|
||||
val fee = buildFee(txFee, tokenFeeStatus)
|
||||
|
||||
return GaslessTransactionData(
|
||||
transaction = transaction,
|
||||
fee = fee,
|
||||
nonce = nonce,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction {
|
||||
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
|
||||
?: error("Ethereum call data is required")
|
||||
|
||||
// Native amount is always zero in gasless transactions for now
|
||||
// we don't support gasless transfers of native currency
|
||||
val nativeAmount = BigInteger.ZERO
|
||||
|
||||
return GaslessTransactionData.Transaction(
|
||||
to = getDestinationAddress(transactionData),
|
||||
value = nativeAmount,
|
||||
data = callData.data,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun buildFee(
|
||||
txFee: TransactionFeeExtended,
|
||||
tokenFeeStatus: CryptoCurrencyStatus,
|
||||
): GaslessTransactionData.Fee {
|
||||
val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token
|
||||
?: error("Fee currency must be a token")
|
||||
|
||||
val feeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency
|
||||
?: error("Fee must be in token currency")
|
||||
|
||||
return GaslessTransactionData.Fee(
|
||||
feeToken = tokenForFee.contractAddress,
|
||||
maxTokenFee = feeInTokenCurrency.gasLimit,
|
||||
coinPriceInToken = feeInTokenCurrency.coinPriceInToken,
|
||||
feeTransferGasLimit = feeInTokenCurrency.feeTransferGasLimit,
|
||||
baseGas = feeInTokenCurrency.baseGas,
|
||||
feeReceiver = gaslessTransactionRepository.getTokenFeeReceiverAddress(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getEIP7702DataForGasless(
|
||||
gaslessDataProvider: EthereumGaslessDataProvider,
|
||||
): EIP7702AuthorizationData {
|
||||
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData()) {
|
||||
is Result.Failure -> throw dataResult.error
|
||||
is Result.Success -> dataResult.data
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
|
||||
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
|
||||
val contractAddress = txData.contractAddress
|
||||
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
|
||||
ethereumCallData.destinationAddress
|
||||
} else {
|
||||
contractAddress ?: error("supports only Token transaction with contract address")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context containing all prepared data for gasless transaction.
|
||||
*/
|
||||
private data class GaslessContext(
|
||||
val walletManager: WalletManager,
|
||||
val gaslessDataProvider: EthereumGaslessDataProvider,
|
||||
val tokenForFeeStatus: CryptoCurrencyStatus,
|
||||
val gaslessTransactionData: GaslessTransactionData,
|
||||
val chainId: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Signed data ready for submission.
|
||||
*/
|
||||
private data class SignedGaslessData(
|
||||
val eip712Signature: String,
|
||||
val eip7702Auth: Eip7702Authorization,
|
||||
)
|
||||
|
||||
private data class HashesToOperate(
|
||||
val eip712Hash: ByteArray,
|
||||
val eip7702Hash: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as HashesToOperate
|
||||
|
||||
if (!eip712Hash.contentEquals(other.eip712Hash)) return false
|
||||
if (!eip7702Hash.contentEquals(other.eip7702Hash)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = eip712Hash.contentHashCode()
|
||||
result = 31 * result + eip7702Hash.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun BigInteger.toFormattedHex(bytes: Int): String {
|
||||
return toByteArray().normalizeByteArray(bytes).toHexString().formatHex()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Builder for creating EIP-712 typed data JSON for gasless transaction signing.
|
||||
*
|
||||
* The EIP-712 standard allows users to sign typed, structured data instead of raw bytes.
|
||||
* This provides better UX as wallets can show users exactly what they're signing.
|
||||
*
|
||||
* Example usage:
|
||||
* ```kotlin
|
||||
* val typedDataJson = Eip712TypedDataBuilder.build(
|
||||
* gaslessTransaction = gaslessTransactionData,
|
||||
* chainId = 1,
|
||||
* verifyingContract = "0x1234..."
|
||||
* )
|
||||
* val signature = wallet.signTypedData(typedDataJson)
|
||||
* ```
|
||||
*/
|
||||
object Eip712TypedDataBuilder {
|
||||
|
||||
private const val DOMAIN_NAME = "Tangem7702GaslessExecutor"
|
||||
private const val DOMAIN_VERSION = "1"
|
||||
private const val PRIMARY_TYPE = "GaslessTransaction"
|
||||
|
||||
/**
|
||||
* Builds EIP-712 typed data JSON for gasless transaction.
|
||||
*
|
||||
* @param gaslessTransaction domain model with transaction and fee data
|
||||
* @param chainId blockchain network chain ID
|
||||
* @param verifyingContract address of the deployed gasless executor contract
|
||||
* @return JSON string ready for EIP-712 signing
|
||||
*/
|
||||
fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String {
|
||||
val typedData = JSONObject().apply {
|
||||
put("types", buildTypes())
|
||||
put("primaryType", PRIMARY_TYPE)
|
||||
put("domain", buildDomain(chainId, verifyingContract))
|
||||
put("message", buildMessage(gaslessTransaction))
|
||||
}
|
||||
return typedData.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the type definitions for all structures.
|
||||
* This schema is fixed and defines the structure of the data being signed.
|
||||
*/
|
||||
@Suppress("NestedScopeFunctions")
|
||||
private fun buildTypes(): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("EIP712Domain", JSONArray().apply {
|
||||
put(typeProperty("name", "string"))
|
||||
put(typeProperty("version", "string"))
|
||||
put(typeProperty("chainId", "uint256"))
|
||||
put(typeProperty("verifyingContract", "address"))
|
||||
})
|
||||
put("Transaction", JSONArray().apply {
|
||||
put(typeProperty("to", "address"))
|
||||
put(typeProperty("value", "uint256"))
|
||||
put(typeProperty("data", "bytes"))
|
||||
})
|
||||
put("Fee", JSONArray().apply {
|
||||
put(typeProperty("feeToken", "address"))
|
||||
put(typeProperty("maxTokenFee", "uint256"))
|
||||
put(typeProperty("coinPriceInToken", "uint256"))
|
||||
put(typeProperty("feeTransferGasLimit", "uint256"))
|
||||
put(typeProperty("baseGas", "uint256"))
|
||||
put(typeProperty("feeReceiver", "address"))
|
||||
})
|
||||
put("GaslessTransaction", JSONArray().apply {
|
||||
put(typeProperty("transaction", "Transaction"))
|
||||
put(typeProperty("fee", "Fee"))
|
||||
put(typeProperty("nonce", "uint256"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a type property JSON object.
|
||||
*/
|
||||
private fun typeProperty(name: String, type: String): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("name", name)
|
||||
put("type", type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the domain separator.
|
||||
*/
|
||||
private fun buildDomain(chainId: Int, verifyingContract: String): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("name", DOMAIN_NAME)
|
||||
put("version", DOMAIN_VERSION)
|
||||
put("chainId", chainId)
|
||||
put("verifyingContract", verifyingContract)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the message data from gasless transaction.
|
||||
*/
|
||||
@Suppress("NestedScopeFunctions")
|
||||
private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("transaction", JSONObject().apply {
|
||||
put("to", gaslessTransaction.transaction.to)
|
||||
put("value", gaslessTransaction.transaction.value.toString())
|
||||
put("data", gaslessTransaction.transaction.data.toHexString())
|
||||
})
|
||||
put("fee", JSONObject().apply {
|
||||
put("feeToken", gaslessTransaction.fee.feeToken)
|
||||
put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString())
|
||||
put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString())
|
||||
put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString())
|
||||
put("baseGas", gaslessTransaction.fee.baseGas.toString())
|
||||
put("feeReceiver", gaslessTransaction.fee.feeReceiver)
|
||||
})
|
||||
put("nonce", gaslessTransaction.nonce.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
class EstimateFeeForGaslessTxUseCase(
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val demoConfig: DemoConfig,
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
private val currenciesRepository: CurrenciesRepository,
|
||||
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
|
||||
private val estimateFeeUseCase: EstimateFeeUseCase,
|
||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||
) {
|
||||
|
||||
private val tokenFeeCalculator = TokenFeeCalculator(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||
demoConfig = demoConfig,
|
||||
)
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
amount: BigDecimal,
|
||||
sendingTokenCurrencyStatus: CryptoCurrencyStatus,
|
||||
): Either<GetFeeError, TransactionFeeExtended> {
|
||||
return either {
|
||||
catch(
|
||||
block = {
|
||||
val network = sendingTokenCurrencyStatus.currency.network
|
||||
val nativeCurrency = currenciesRepository.getNetworkCoin(
|
||||
userWalletId = userWallet.walletId,
|
||||
networkId = network.id,
|
||||
derivationPath = network.derivationPath,
|
||||
)
|
||||
|
||||
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) {
|
||||
estimateFeeUseCase.invoke(
|
||||
userWallet = userWallet,
|
||||
amount = amount,
|
||||
cryptoCurrencyStatus = sendingTokenCurrencyStatus,
|
||||
).fold(
|
||||
ifLeft = { raise(it) },
|
||||
ifRight = { fee ->
|
||||
return@either TransactionFeeExtended(
|
||||
transactionFee = fee,
|
||||
feeTokenId = nativeCurrency.id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val walletManager = prepareWalletManager(userWallet, network)
|
||||
|
||||
val initialFee = tokenFeeCalculator.estimateInitialFee(
|
||||
userWallet = userWallet,
|
||||
amount = amount,
|
||||
txTokenCurrencyStatus = sendingTokenCurrencyStatus,
|
||||
).bind()
|
||||
|
||||
selectFeePaymentStrategy(
|
||||
userWallet = userWallet,
|
||||
walletManager = walletManager,
|
||||
nativeCurrency = nativeCurrency,
|
||||
network = network,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
},
|
||||
catch = {
|
||||
raise(GaslessError.DataError(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
private suspend fun Raise<GetFeeError>.prepareWalletManager(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): EthereumWalletManager {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWallet.walletId,
|
||||
network = network,
|
||||
)
|
||||
val ethereumWalletManager = walletManager as? EthereumWalletManager
|
||||
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
|
||||
return ethereumWalletManager
|
||||
}
|
||||
|
||||
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
|
||||
userWallet: UserWallet,
|
||||
walletManager: EthereumWalletManager,
|
||||
nativeCurrency: CryptoCurrency,
|
||||
network: Network,
|
||||
initialFee: TransactionFee,
|
||||
): TransactionFeeExtended {
|
||||
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
|
||||
|
||||
val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
|
||||
userWallet.walletId,
|
||||
).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
|
||||
|
||||
val networkCurrenciesStatuses = userCurrenciesStatuses.filter {
|
||||
it.currency.network.id == network.id
|
||||
}
|
||||
|
||||
val nativeCurrencyStatus = networkCurrenciesStatuses.find {
|
||||
it.currency.id == nativeCurrency.id
|
||||
} ?: raiseIllegalStateError("native currency not found for network ${network.id}")
|
||||
|
||||
val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO
|
||||
return if (nativeBalance >= feeValue) {
|
||||
TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id)
|
||||
} else {
|
||||
findTokensToPayFee(
|
||||
walletManager = walletManager,
|
||||
initialTxFee = initialFee,
|
||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||
networkCurrenciesStatuses = networkCurrenciesStatuses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
private suspend fun Raise<GetFeeError>.findTokensToPayFee(
|
||||
walletManager: EthereumWalletManager,
|
||||
initialTxFee: TransactionFee,
|
||||
nativeCurrencyStatus: CryptoCurrencyStatus,
|
||||
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
|
||||
): TransactionFeeExtended {
|
||||
val initialFee = initialTxFee.normal as? Fee.Ethereum
|
||||
?: raiseIllegalStateError(
|
||||
error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}",
|
||||
)
|
||||
|
||||
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
|
||||
network = nativeCurrencyStatus.currency.network,
|
||||
).mapNotNull { currency ->
|
||||
(currency as? CryptoCurrency.Token)?.contractAddress
|
||||
}.toSet()
|
||||
|
||||
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
|
||||
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
|
||||
.sortedByDescending { it.value.amount }
|
||||
.filter { status ->
|
||||
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
|
||||
token.contractAddress.lowercase() in supportedGaslessTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects token with highest balance to maximize chances of successful fee payment.
|
||||
* Returns null if no suitable tokens found.
|
||||
*/
|
||||
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
|
||||
?: raise(GaslessError.NoSupportedTokensFound)
|
||||
|
||||
return tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = walletManager,
|
||||
tokenForPayFeeStatus = tokenForPayFeeStatus,
|
||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||
initialFee = initialFee,
|
||||
).bind()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import java.math.BigDecimal
|
||||
|
||||
class EstimateFeeForTokenUseCase(
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val demoConfig: DemoConfig,
|
||||
private val currenciesRepository: CurrenciesRepository,
|
||||
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
|
||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||
) {
|
||||
|
||||
private val tokenFeeCalculator = TokenFeeCalculator(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||
demoConfig = demoConfig,
|
||||
)
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
feeTokenCurrencyStatus: CryptoCurrencyStatus,
|
||||
sendingTokenCurrencyStatus: CryptoCurrencyStatus,
|
||||
amount: BigDecimal,
|
||||
): Either<GetFeeError, TransactionFeeExtended> {
|
||||
return either {
|
||||
catch(
|
||||
block = {
|
||||
val token = feeTokenCurrencyStatus.currency
|
||||
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) {
|
||||
raise(GaslessError.NetworkIsNotSupported)
|
||||
}
|
||||
|
||||
val initialTxFee = tokenFeeCalculator.estimateInitialFee(
|
||||
userWallet = userWallet,
|
||||
amount = amount,
|
||||
txTokenCurrencyStatus = sendingTokenCurrencyStatus,
|
||||
).bind()
|
||||
|
||||
val initialFeeEth = initialTxFee.normal as? Fee.Ethereum
|
||||
?: raiseIllegalStateError(
|
||||
error = "only Fee.Ethereum supported, but was different",
|
||||
)
|
||||
|
||||
val nativeCurrency = currenciesRepository.getNetworkCoin(
|
||||
userWalletId = userWallet.walletId,
|
||||
networkId = token.network.id,
|
||||
derivationPath = token.network.derivationPath,
|
||||
)
|
||||
|
||||
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
|
||||
userWallet.walletId,
|
||||
).getOrNull()?.filter {
|
||||
it.currency.network.id == token.network.id
|
||||
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
|
||||
|
||||
val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find {
|
||||
it.currency.id == nativeCurrency.id
|
||||
} ?: raiseIllegalStateError("native currency not found for network ${token.network.id}")
|
||||
|
||||
val walletManager = prepareWalletManager(userWallet, token.network)
|
||||
|
||||
tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = walletManager,
|
||||
tokenForPayFeeStatus = feeTokenCurrencyStatus,
|
||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||
initialFee = initialFeeEth,
|
||||
).bind()
|
||||
},
|
||||
catch = {
|
||||
raise(GaslessError.DataError(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
private suspend fun Raise<GetFeeError>.prepareWalletManager(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): EthereumWalletManager {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWallet.walletId,
|
||||
network = network,
|
||||
)
|
||||
val ethereumWalletManager = walletManager as? EthereumWalletManager
|
||||
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
|
||||
return ethereumWalletManager
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||
|
||||
class GetAvailableFeeTokensUseCase(
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
private val currenciesRepository: CurrenciesRepository,
|
||||
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
|
||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Retrieves available tokens for gasless fee payment.
|
||||
*
|
||||
* @return List where first element is always native currency (for fallback)
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): Either<GetFeeError, List<CryptoCurrencyStatus>> {
|
||||
return either {
|
||||
catch(
|
||||
block = {
|
||||
val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
|
||||
userWallet.walletId,
|
||||
).getOrNull()
|
||||
?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
|
||||
|
||||
val nativeCurrencyStatus = getNativeCurrencyStatus(userWallet, network, userCurrenciesStatuses)
|
||||
|
||||
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) {
|
||||
return@either listOf(nativeCurrencyStatus)
|
||||
}
|
||||
|
||||
val gaslessTokens = getGaslessTokens(network, userCurrenciesStatuses)
|
||||
buildList {
|
||||
add(nativeCurrencyStatus)
|
||||
addAll(gaslessTokens)
|
||||
}
|
||||
},
|
||||
catch = {
|
||||
raise(GetFeeError.GaslessError.DataError(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun Raise<GetFeeError>.getNativeCurrencyStatus(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
userCurrenciesStatuses: List<CryptoCurrencyStatus>,
|
||||
): CryptoCurrencyStatus {
|
||||
val nativeCurrency = currenciesRepository.getNetworkCoin(
|
||||
userWalletId = userWallet.walletId,
|
||||
networkId = network.id,
|
||||
derivationPath = network.derivationPath,
|
||||
)
|
||||
return userCurrenciesStatuses.find {
|
||||
it.currency.id == nativeCurrency.id
|
||||
} ?: raiseIllegalStateError("no native currency found")
|
||||
}
|
||||
|
||||
private suspend fun getGaslessTokens(
|
||||
network: Network,
|
||||
userCurrenciesStatuses: List<CryptoCurrencyStatus>,
|
||||
): List<CryptoCurrencyStatus> {
|
||||
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(network)
|
||||
.mapNotNull {
|
||||
(it as? CryptoCurrency.Token)?.contractAddress?.lowercase()
|
||||
}.toSet()
|
||||
return userCurrenciesStatuses
|
||||
.asSequence()
|
||||
.filter { it.value.yieldSupplyStatus == null }
|
||||
.filter { it.currency.network.id == network.id }
|
||||
.filter { currencyStatus ->
|
||||
val token = currencyStatus.currency
|
||||
token is CryptoCurrency.Token && supportedGaslessTokens.contains(token.contractAddress.lowercase())
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
class GetFeeForGaslessUseCase(
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val demoConfig: DemoConfig,
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
private val currenciesRepository: CurrenciesRepository,
|
||||
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
|
||||
private val getFeeUseCase: GetFeeUseCase,
|
||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||
) {
|
||||
|
||||
private val tokenFeeCalculator = TokenFeeCalculator(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||
demoConfig = demoConfig,
|
||||
)
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
transactionData: TransactionData,
|
||||
): Either<GetFeeError, TransactionFeeExtended> {
|
||||
return either {
|
||||
catch(
|
||||
block = {
|
||||
val nativeCurrency = currenciesRepository.getNetworkCoin(
|
||||
userWalletId = userWallet.walletId,
|
||||
networkId = network.id,
|
||||
derivationPath = network.derivationPath,
|
||||
)
|
||||
|
||||
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(network)) {
|
||||
getFeeUseCase.invoke(userWallet, network, transactionData).fold(
|
||||
ifLeft = { raise(it) },
|
||||
ifRight = { fee ->
|
||||
return@either TransactionFeeExtended(
|
||||
transactionFee = fee,
|
||||
feeTokenId = nativeCurrency.id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val walletManager = prepareWalletManager(userWallet, network)
|
||||
|
||||
val initialFee = tokenFeeCalculator.calculateInitialFee(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
walletManager = walletManager,
|
||||
transactionData = transactionData,
|
||||
).bind()
|
||||
|
||||
selectFeePaymentStrategy(
|
||||
userWallet = userWallet,
|
||||
walletManager = walletManager,
|
||||
nativeCurrency = nativeCurrency,
|
||||
network = network,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
},
|
||||
catch = {
|
||||
raise(GaslessError.DataError(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
private suspend fun Raise<GetFeeError>.prepareWalletManager(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): EthereumWalletManager {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWallet.walletId,
|
||||
network = network,
|
||||
)
|
||||
val ethereumWalletManager = walletManager as? EthereumWalletManager
|
||||
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
|
||||
return ethereumWalletManager
|
||||
}
|
||||
|
||||
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
|
||||
userWallet: UserWallet,
|
||||
walletManager: EthereumWalletManager,
|
||||
nativeCurrency: CryptoCurrency,
|
||||
network: Network,
|
||||
initialFee: TransactionFee,
|
||||
): TransactionFeeExtended {
|
||||
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
|
||||
|
||||
val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
|
||||
userWallet.walletId,
|
||||
).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
|
||||
|
||||
val networkCurrenciesStatuses = userCurrenciesStatuses.filter {
|
||||
it.currency.network.id == network.id
|
||||
}
|
||||
|
||||
val nativeCurrencyStatus = networkCurrenciesStatuses.find {
|
||||
it.currency.id == nativeCurrency.id
|
||||
} ?: raiseIllegalStateError("native currency not found for network ${network.id}")
|
||||
|
||||
val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO
|
||||
return if (nativeBalance >= feeValue) {
|
||||
TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id)
|
||||
} else {
|
||||
findTokensToPayFee(
|
||||
walletManager = walletManager,
|
||||
initialTxFee = initialFee,
|
||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||
networkCurrenciesStatuses = networkCurrenciesStatuses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
private suspend fun Raise<GetFeeError>.findTokensToPayFee(
|
||||
walletManager: EthereumWalletManager,
|
||||
initialTxFee: TransactionFee,
|
||||
nativeCurrencyStatus: CryptoCurrencyStatus,
|
||||
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
|
||||
): TransactionFeeExtended {
|
||||
val initialFee = initialTxFee.normal as? Fee.Ethereum
|
||||
?: raiseIllegalStateError(
|
||||
error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}",
|
||||
)
|
||||
|
||||
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
|
||||
network = nativeCurrencyStatus.currency.network,
|
||||
).mapNotNull { currency ->
|
||||
(currency as? CryptoCurrency.Token)?.contractAddress
|
||||
}.toSet()
|
||||
|
||||
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
|
||||
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
|
||||
.sortedByDescending { it.value.amount }
|
||||
.filter { status ->
|
||||
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
|
||||
token.contractAddress.lowercase() in supportedGaslessTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects token with highest balance to maximize chances of successful fee payment.
|
||||
* Returns null if no suitable tokens found.
|
||||
*/
|
||||
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
|
||||
?: raise(GaslessError.NoSupportedTokensFound)
|
||||
|
||||
return tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = walletManager,
|
||||
tokenForPayFeeStatus = tokenForPayFeeStatus,
|
||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||
initialFee = initialFee,
|
||||
).bind()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
|
||||
class GetFeeForTokenUseCase(
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val demoConfig: DemoConfig,
|
||||
private val currenciesRepository: CurrenciesRepository,
|
||||
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
|
||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||
) {
|
||||
|
||||
private val tokenFeeCalculator = TokenFeeCalculator(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||
demoConfig = demoConfig,
|
||||
)
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
token: CryptoCurrency,
|
||||
transactionData: TransactionData,
|
||||
): Either<GetFeeError, TransactionFeeExtended> {
|
||||
return either {
|
||||
catch(
|
||||
block = {
|
||||
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) {
|
||||
raise(GaslessError.NetworkIsNotSupported)
|
||||
}
|
||||
|
||||
val walletManager = prepareWalletManager(userWallet, token.network)
|
||||
|
||||
val initialTxFee = tokenFeeCalculator.calculateInitialFee(
|
||||
userWallet = userWallet,
|
||||
network = token.network,
|
||||
walletManager = walletManager,
|
||||
transactionData = transactionData,
|
||||
).bind()
|
||||
|
||||
val initialFeeEth = initialTxFee.normal as? Fee.Ethereum
|
||||
?: raiseIllegalStateError(
|
||||
error = "only Fee.Ethereum supported, but was different",
|
||||
)
|
||||
|
||||
val nativeCurrency = currenciesRepository.getNetworkCoin(
|
||||
userWalletId = userWallet.walletId,
|
||||
networkId = token.network.id,
|
||||
derivationPath = token.network.derivationPath,
|
||||
)
|
||||
|
||||
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
|
||||
userWallet.walletId,
|
||||
).getOrNull()?.filter {
|
||||
it.currency.network.id == token.network.id
|
||||
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
|
||||
|
||||
val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find {
|
||||
it.currency.id == nativeCurrency.id
|
||||
} ?: raiseIllegalStateError("native currency not found for network ${token.network.id}")
|
||||
|
||||
val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find {
|
||||
it.currency.id == token.id
|
||||
} ?: raiseIllegalStateError("token currency not found for network ${token.network.id}")
|
||||
|
||||
tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = walletManager,
|
||||
tokenForPayFeeStatus = tokenCurrencyStatus,
|
||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||
initialFee = initialFeeEth,
|
||||
).bind()
|
||||
},
|
||||
catch = {
|
||||
raise(GaslessError.DataError(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
private suspend fun Raise<GetFeeError>.prepareWalletManager(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): EthereumWalletManager {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWallet.walletId,
|
||||
network = network,
|
||||
)
|
||||
val ethereumWalletManager = walletManager as? EthereumWalletManager
|
||||
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
|
||||
return ethereumWalletManager
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
|
||||
/**
|
||||
* Use case to check if gasless fee is supported for a given network.
|
||||
*/
|
||||
class IsGaslessFeeSupportedForNetwork(
|
||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(network: Network): Boolean {
|
||||
return currencyChecksRepository.isNetworkSupportedForGaslessTx(network)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.domain.demo.DemoTransactionSender
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||
import com.tangem.domain.utils.convertToSdkAmount
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.extensions.isZero
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.RoundingMode
|
||||
|
||||
internal class TokenFeeCalculator(
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
private val demoConfig: DemoConfig,
|
||||
) {
|
||||
|
||||
suspend fun calculateInitialFee(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
walletManager: EthereumWalletManager,
|
||||
transactionData: TransactionData,
|
||||
): Either<GetFeeError, TransactionFee> {
|
||||
return either {
|
||||
val transactionSender = if (userWallet is UserWallet.Cold &&
|
||||
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
|
||||
) {
|
||||
demoTransactionSender(userWallet, network)
|
||||
} else {
|
||||
walletManager
|
||||
}
|
||||
|
||||
val maybeFee = when (val result = transactionSender.getFee(transactionData = transactionData)) {
|
||||
is Result.Success -> result.data
|
||||
is Result.Failure -> raise(GaslessError.DataError(result.error))
|
||||
}
|
||||
maybeFee
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun estimateInitialFee(
|
||||
userWallet: UserWallet,
|
||||
amount: BigDecimal,
|
||||
txTokenCurrencyStatus: CryptoCurrencyStatus,
|
||||
): Either<GetFeeError, TransactionFee> {
|
||||
return either {
|
||||
val network = txTokenCurrencyStatus.currency.network
|
||||
val amountData = amount.convertToSdkAmount(txTokenCurrencyStatus)
|
||||
val result = if (userWallet is UserWallet.Cold &&
|
||||
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
|
||||
) {
|
||||
demoTransactionSender(userWallet, network).estimateFee(
|
||||
amount = amountData,
|
||||
destination = "",
|
||||
)
|
||||
} else {
|
||||
walletManagersFacade.estimateFee(
|
||||
amount = amountData,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = network,
|
||||
)
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is Result.Success -> result.data
|
||||
is Result.Failure -> raise(GaslessError.DataError(result.error))
|
||||
null -> raise(GetFeeError.UnknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun calculateTokenFee(
|
||||
walletManager: EthereumWalletManager,
|
||||
tokenForPayFeeStatus: CryptoCurrencyStatus,
|
||||
nativeCurrencyStatus: CryptoCurrencyStatus,
|
||||
initialFee: Fee.Ethereum,
|
||||
): Either<GetFeeError, TransactionFeeExtended> {
|
||||
return either {
|
||||
// fast finish to skip calculations if no funds in token
|
||||
if (tokenForPayFeeStatus.value.amount?.isZero() == true) {
|
||||
raise(GaslessError.NotEnoughFunds)
|
||||
}
|
||||
|
||||
val tokenForPayFee = tokenForPayFeeStatus.currency as? CryptoCurrency.Token
|
||||
?: raiseIllegalStateError("only tokens are supported")
|
||||
|
||||
val transferFeeToContractAmount = createTokenAmount(
|
||||
token = tokenForPayFee,
|
||||
value = BigDecimal(FEE_TRANSFER_AMOUNT),
|
||||
)
|
||||
val feeDestination = gaslessTransactionRepository.getTokenFeeReceiverAddress()
|
||||
val feeTransferGasLimitResult = walletManager.getGasLimit(
|
||||
amount = transferFeeToContractAmount,
|
||||
destination = feeDestination,
|
||||
callData = TransferERC20TokenCallData(
|
||||
destination = feeDestination,
|
||||
amount = transferFeeToContractAmount,
|
||||
),
|
||||
)
|
||||
|
||||
val feeTransferGasLimit = when (feeTransferGasLimitResult) {
|
||||
is Result.Failure -> raise(GaslessError.DataError(feeTransferGasLimitResult.error))
|
||||
is Result.Success -> feeTransferGasLimitResult.data
|
||||
}.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
|
||||
|
||||
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
|
||||
|
||||
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas
|
||||
|
||||
val maxFeePerGas = when (initialFee) {
|
||||
is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas
|
||||
is Fee.Ethereum.Legacy -> initialFee.gasPrice
|
||||
is Fee.Ethereum.TokenCurrency -> raiseIllegalStateError("initialFee could only be native")
|
||||
}
|
||||
|
||||
val feeInNativeCurrency = BigDecimal(maxTokenFeeGas.multiply(maxFeePerGas))
|
||||
.multiply(BigDecimal(GAS_PRICE_MULTIPLIER))
|
||||
.setScale(0, RoundingMode.UP)
|
||||
.toBigInteger()
|
||||
|
||||
val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null")
|
||||
val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null")
|
||||
|
||||
val coinPriceInTokenInBigDecimal = nativeFiatRate.divide(
|
||||
tokenFiatRate,
|
||||
maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals),
|
||||
RoundingMode.UP,
|
||||
)
|
||||
|
||||
val coinPriceInTokenBigInt = coinPriceInTokenInBigDecimal
|
||||
.movePointRight(tokenForPayFee.decimals)
|
||||
.increaseByPercent(PERCENT_TO_INCREASE_TOKEN_PRICE)
|
||||
.toBigInteger()
|
||||
|
||||
val feeInTokenCurrency = coinPriceInTokenInBigDecimal.multiply(
|
||||
feeInNativeCurrency
|
||||
.toBigDecimal()
|
||||
.movePointLeft(nativeCurrencyStatus.currency.decimals),
|
||||
)
|
||||
|
||||
val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO
|
||||
if (tokenBalance < feeInTokenCurrency) {
|
||||
raise(GaslessError.NotEnoughFunds)
|
||||
}
|
||||
|
||||
val amount = createTokenAmount(tokenForPayFee, feeInTokenCurrency)
|
||||
|
||||
val fee = Fee.Ethereum.TokenCurrency(
|
||||
amount = amount,
|
||||
gasLimit = maxTokenFeeGas,
|
||||
coinPriceInToken = coinPriceInTokenBigInt,
|
||||
feeTransferGasLimit = feeTransferGasLimit,
|
||||
baseGas = baseGas,
|
||||
)
|
||||
TransactionFeeExtended(
|
||||
transactionFee = TransactionFee.Single(normal = fee),
|
||||
feeTokenId = tokenForPayFee.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
|
||||
token = Token(
|
||||
symbol = token.symbol,
|
||||
contractAddress = token.contractAddress,
|
||||
decimals = token.decimals,
|
||||
),
|
||||
value = value,
|
||||
)
|
||||
|
||||
private suspend fun Raise<GetFeeError>.demoTransactionSender(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): DemoTransactionSender {
|
||||
return DemoTransactionSender(
|
||||
walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
|
||||
?: raiseIllegalStateError("WalletManager is null"),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Amount in token units for fee transfer */
|
||||
const val FEE_TRANSFER_AMOUNT = 0.01 // calculate using decimals
|
||||
/** Gas price safety multiplier for fee calculation */
|
||||
const val GAS_PRICE_MULTIPLIER = 1.5
|
||||
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
|
||||
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
|
||||
|
||||
/**
|
||||
* Increases BigDecimal value by specified percentage.
|
||||
*
|
||||
* @param percent percentage to increase by (e.g., 1 for 1%, 10 for 10%)
|
||||
* @return value increased by specified percentage (value * (1 + percent/100))
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* BigDecimal("100").increaseByPercent(1) // 101
|
||||
* BigDecimal("100").increaseByPercent(10) // 110
|
||||
* BigDecimal("100").increaseByPercent(50) // 150
|
||||
* ```
|
||||
*/
|
||||
private fun BigDecimal.increaseByPercent(percent: Int): BigDecimal {
|
||||
require(percent >= 0) { "Percent must be non-negative" }
|
||||
val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100")))
|
||||
return this.multiply(multiplier)
|
||||
}
|
||||
|
||||
private fun BigInteger.increaseByPercent(percent: Int): BigInteger {
|
||||
require(percent >= 0) { "Percent must be non-negative" }
|
||||
val multiplier = BigDecimal.ONE.add(BigDecimal(percent).divide(BigDecimal("100")))
|
||||
return BigDecimal(this).multiply(multiplier).toBigInteger()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.BlockchainSdkError
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Unit tests for [TokenFeeCalculator].
|
||||
* Tests cover fee calculation logic, error handling, and edge cases.
|
||||
*/
|
||||
class TokenFeeCalculatorTest {
|
||||
|
||||
private lateinit var walletManagersFacade: WalletManagersFacade
|
||||
private lateinit var gaslessTransactionRepository: GaslessTransactionRepository
|
||||
private lateinit var demoConfig: DemoConfig
|
||||
private lateinit var tokenFeeCalculator: TokenFeeCalculator
|
||||
|
||||
private lateinit var mockWalletManager: EthereumWalletManager
|
||||
private lateinit var mockNetwork: Network
|
||||
private lateinit var mockUserWallet: UserWallet
|
||||
private lateinit var mockUserWalletId: UserWalletId
|
||||
private lateinit var mockTransactionData: TransactionData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
walletManagersFacade = mockk()
|
||||
gaslessTransactionRepository = mockk()
|
||||
demoConfig = mockk()
|
||||
|
||||
tokenFeeCalculator = TokenFeeCalculator(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||
demoConfig = demoConfig,
|
||||
)
|
||||
|
||||
mockWalletManager = mockk()
|
||||
mockNetwork = mockk()
|
||||
mockUserWallet = mockk<UserWallet.Hot>()
|
||||
mockUserWalletId = mockk()
|
||||
mockTransactionData = mockk()
|
||||
|
||||
|
||||
// Default mock behavior
|
||||
every { demoConfig.isDemoCardId(any()) } returns false
|
||||
every { mockUserWallet.walletId } returns mockUserWalletId
|
||||
}
|
||||
|
||||
// ===== calculateInitialFee Tests =====
|
||||
|
||||
@Test
|
||||
fun `calculateInitialFee should return success when getFee succeeds`() = runTest {
|
||||
// Given
|
||||
val expectedFee = createMockTransactionFee()
|
||||
coEvery { mockWalletManager.getFee(mockTransactionData) } returns Result.Success(expectedFee)
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateInitialFee(
|
||||
userWallet = mockUserWallet,
|
||||
network = mockNetwork,
|
||||
walletManager = mockWalletManager,
|
||||
transactionData = mockTransactionData,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isRight())
|
||||
result.onRight { fee ->
|
||||
assertEquals(expectedFee, fee)
|
||||
}
|
||||
coVerify { mockWalletManager.getFee(mockTransactionData) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateInitialFee should return error when getFee fails`() = runTest {
|
||||
// Given
|
||||
val failure = Result.Failure(BlockchainSdkError.Ethereum.Api(1, "Failed to get fee"))
|
||||
coEvery { mockWalletManager.getFee(mockTransactionData) } returns failure
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateInitialFee(
|
||||
userWallet = mockUserWallet,
|
||||
network = mockNetwork,
|
||||
walletManager = mockWalletManager,
|
||||
transactionData = mockTransactionData,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isLeft())
|
||||
result.onLeft { error ->
|
||||
assertTrue(error is GetFeeError.GaslessError)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== estimateInitialFee Tests =====
|
||||
|
||||
@Test
|
||||
fun `estimateInitialFee should return success when estimation succeeds`() = runTest {
|
||||
// Given
|
||||
val amount = BigDecimal("100")
|
||||
val tokenStatus = createMockTokenStatus()
|
||||
val expectedFee = createMockTransactionFee()
|
||||
|
||||
coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns Result.Success(expectedFee)
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.estimateInitialFee(
|
||||
userWallet = mockUserWallet,
|
||||
amount = amount,
|
||||
txTokenCurrencyStatus = tokenStatus,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isRight())
|
||||
result.onRight { fee ->
|
||||
assertEquals(expectedFee, fee)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateInitialFee should return error when estimation fails`() = runTest {
|
||||
// Given
|
||||
val amount = BigDecimal("100")
|
||||
val tokenStatus = createMockTokenStatus()
|
||||
val failure = Result.Failure(BlockchainSdkError.NPError("Estimation failed"))
|
||||
|
||||
coEvery {
|
||||
walletManagersFacade.estimateFee(any(), any(), any())
|
||||
} returns failure
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.estimateInitialFee(
|
||||
userWallet = mockUserWallet,
|
||||
amount = amount,
|
||||
txTokenCurrencyStatus = tokenStatus,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isLeft())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateInitialFee should return error when result is null`() = runTest {
|
||||
// Given
|
||||
val amount = BigDecimal("100")
|
||||
val tokenStatus = createMockTokenStatus()
|
||||
|
||||
coEvery { walletManagersFacade.estimateFee(any(), any(), any()) } returns null
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.estimateInitialFee(
|
||||
userWallet = mockUserWallet,
|
||||
amount = amount,
|
||||
txTokenCurrencyStatus = tokenStatus,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isLeft())
|
||||
result.onLeft { error ->
|
||||
assertTrue(error is GetFeeError.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== calculateTokenFee Tests =====
|
||||
|
||||
@Test
|
||||
fun `calculateTokenFee should calculate correct fee for token payment`() = runTest {
|
||||
// Given
|
||||
val tokenStatus = createMockTokenStatus(
|
||||
balance = BigDecimal("1000"),
|
||||
fiatRate = BigDecimal("1"), // 1 USDC = 1 USD
|
||||
)
|
||||
val nativeStatus = createMockNativeCurrencyStatus(
|
||||
fiatRate = BigDecimal("2000"), // 1 ETH = 2000 USD
|
||||
)
|
||||
val initialFee = createMockEIP1559Fee()
|
||||
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isRight())
|
||||
result.onRight { feeExtended ->
|
||||
assertNotNull(feeExtended)
|
||||
assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId)
|
||||
assertTrue(feeExtended.transactionFee is TransactionFee.Single)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateTokenFee should return error when token balance is insufficient`() = runTest {
|
||||
// Given
|
||||
val tokenStatus = createMockTokenStatus(
|
||||
balance = BigDecimal("0.001"), // Very small balance
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val nativeStatus = createMockNativeCurrencyStatus(
|
||||
fiatRate = BigDecimal("2000"),
|
||||
)
|
||||
val initialFee = createMockEIP1559Fee()
|
||||
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isLeft())
|
||||
result.onLeft { error ->
|
||||
assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateTokenFee should return error when fiatRate is null`() = runTest {
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||
// Given
|
||||
val tokenStatus = createMockTokenStatus(
|
||||
balance = BigDecimal("1000"),
|
||||
fiatRate = null, // No fiat rate
|
||||
)
|
||||
val nativeStatus = createMockNativeCurrencyStatus(
|
||||
fiatRate = BigDecimal("2000"),
|
||||
)
|
||||
val initialFee = createMockEIP1559Fee()
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isLeft())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateTokenFee should return error when getGasLimit fails`() = runTest {
|
||||
// Given
|
||||
val tokenStatus = createMockTokenStatus()
|
||||
val nativeStatus = createMockNativeCurrencyStatus()
|
||||
val initialFee = createMockEIP1559Fee()
|
||||
|
||||
val failure = Result.Failure(BlockchainSdkError.NPError("Gas limit fetch failed"))
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns failure
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isLeft())
|
||||
result.onLeft { error ->
|
||||
assertTrue(error is GetFeeError.GaslessError.DataError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateTokenFee should handle Legacy fee type correctly`() = runTest {
|
||||
// Given
|
||||
val tokenStatus = createMockTokenStatus()
|
||||
val nativeStatus = createMockNativeCurrencyStatus()
|
||||
val initialFee = Fee.Ethereum.Legacy(
|
||||
amount = mockk(relaxed = true),
|
||||
gasLimit = BigInteger("100000"),
|
||||
gasPrice = BigInteger("50000000000"), // 50 Gwei
|
||||
)
|
||||
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isRight())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateTokenFee should reject TokenCurrency as initial fee`() = runTest {
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||
|
||||
// Given
|
||||
val tokenStatus = createMockTokenStatus()
|
||||
val nativeStatus = createMockNativeCurrencyStatus()
|
||||
val initialFee = Fee.Ethereum.TokenCurrency(
|
||||
amount = mockk(relaxed = true),
|
||||
gasLimit = BigInteger("100000"),
|
||||
coinPriceInToken = BigInteger("1000"),
|
||||
feeTransferGasLimit = BigInteger("60000"),
|
||||
baseGas = BigInteger("21000"),
|
||||
)
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isLeft())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateTokenFee should apply 1 percent increase to token price`() = runTest {
|
||||
// Expected
|
||||
|
||||
val expectedAmount = Amount(
|
||||
value = BigDecimal("28.050000000000000000000000000000000000"),
|
||||
token = Token(
|
||||
name = "USDC",
|
||||
symbol = "USDC",
|
||||
contractAddress = "0xUSDC",
|
||||
decimals = 6,
|
||||
)
|
||||
)
|
||||
val expectedGasLimit = "187000".toBigInteger()
|
||||
val expectedCoinPriceInToken = BigInteger("2020000000") // 2000 * 1.01 * 10^6
|
||||
val expectedFeeTransferLimit = "66000".toBigInteger()
|
||||
val expectedBaseGas = "21000".toBigInteger()
|
||||
|
||||
// Given
|
||||
val tokenStatus = createMockTokenStatus(
|
||||
balance = BigDecimal("1000"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
decimals = 6,
|
||||
)
|
||||
val nativeStatus = createMockNativeCurrencyStatus(
|
||||
fiatRate = BigDecimal("2000"),
|
||||
decimals = 18,
|
||||
)
|
||||
val initialFee = createMockEIP1559Fee()
|
||||
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isRight())
|
||||
result.onRight { feeExtended ->
|
||||
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
|
||||
|
||||
assertEquals(expectedAmount, fee.amount)
|
||||
assertEquals(expectedGasLimit, fee.gasLimit)
|
||||
assertEquals(expectedCoinPriceInToken, fee.coinPriceInToken)
|
||||
assertEquals(expectedFeeTransferLimit, fee.feeTransferGasLimit)
|
||||
assertEquals(expectedBaseGas, fee.baseGas)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
private fun createMockTransactionFee(): TransactionFee {
|
||||
val fee = Fee.Ethereum.EIP1559(
|
||||
amount = mockk(relaxed = true),
|
||||
gasLimit = BigInteger("100000"),
|
||||
maxFeePerGas = BigInteger("50000000000"),
|
||||
priorityFee = BigInteger("1000000000"),
|
||||
)
|
||||
return TransactionFee.Single(normal = fee)
|
||||
}
|
||||
|
||||
private fun createMockEIP1559Fee(): Fee.Ethereum.EIP1559 {
|
||||
return Fee.Ethereum.EIP1559(
|
||||
amount = mockk(relaxed = true),
|
||||
gasLimit = BigInteger("100000"),
|
||||
maxFeePerGas = BigInteger("50000000000"), // 50 Gwei
|
||||
priorityFee = BigInteger("1000000000"), // 1 Gwei
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMockTokenStatus(
|
||||
balance: BigDecimal = BigDecimal("1000"),
|
||||
fiatRate: BigDecimal? = BigDecimal("1"),
|
||||
decimals: Int = 6,
|
||||
): CryptoCurrencyStatus {
|
||||
val token = mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
every { token.symbol } returns "USDC"
|
||||
every { token.contractAddress } returns "0xUSDC"
|
||||
every { token.decimals } returns decimals
|
||||
every { token.network } returns mockNetwork
|
||||
every { token.id } returns mockk(relaxed = true)
|
||||
|
||||
val status = mockk<CryptoCurrencyStatus>()
|
||||
every { status.currency } returns token
|
||||
every { status.value.amount } returns balance
|
||||
every { status.value.fiatRate } returns fiatRate
|
||||
every { status.value.yieldSupplyStatus } returns null
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
private fun createMockNativeCurrencyStatus(
|
||||
fiatRate: BigDecimal? = BigDecimal("2000"),
|
||||
decimals: Int = 18,
|
||||
): CryptoCurrencyStatus {
|
||||
val coin = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
every { coin.symbol } returns "ETH"
|
||||
every { coin.decimals } returns decimals
|
||||
every { coin.network } returns mockNetwork
|
||||
|
||||
val status = mockk<CryptoCurrencyStatus>()
|
||||
every { status.currency } returns coin
|
||||
every { status.value.fiatRate } returns fiatRate
|
||||
every { status.value.yieldSupplyStatus } returns null
|
||||
|
||||
return status
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue