From 8564f21d01125ffb277a200663b86f50f1355472 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 7 Jan 2026 14:23:34 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultGaslessTransactionRepository.kt | 41 +++--- .../GaslessTxDataToGaslessRequestConverter.kt | 4 +- .../models/GaslessTransactionData.kt | 91 ++++++------- .../CreateAndSendGaslessTransactionUseCase.kt | 126 +++++++++++++++++- .../usecase/gasless/Eip712TypedDataBuilder.kt | 124 +++++++++++++++++ .../gasless/GetAvailableFeeTokensUseCase.kt | 9 +- .../gasless/GetFeeForGaslessUseCase.kt | 17 ++- .../usecase/gasless/TokenFeeCalculator.kt | 38 +++++- gradle/tangem_dependencies.toml | 2 +- 9 files changed, 369 insertions(+), 83 deletions(-) create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index e68999f26f..7a4a698b9e 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -26,7 +26,7 @@ class DefaultGaslessTransactionRepository( private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ) : GaslessTransactionRepository { - private val supportedTokensState = MutableStateFlow?>(null) + private val supportedTokensState = MutableStateFlow>>(hashMapOf()) private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() @@ -37,28 +37,37 @@ class DefaultGaslessTransactionRepository( override suspend fun getSupportedTokens(network: Network): Set { return withContext(coroutineDispatcherProvider.io) { - val storedTokens = supportedTokensState.value + val storedTokens = supportedTokensState.value[network.id] if (storedTokens != null && storedTokens.isNotEmpty()) { return@withContext storedTokens } val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() if (supportedTokensData.isSuccess) { - val supportedTokens = supportedTokensData.result.tokens.mapNotNull { token -> - val blockchain = Blockchain.fromChainId(token.chainId) ?: return@mapNotNull null - responseCryptoCurrenciesFactory.createToken( - blockchain = blockchain, - sdkToken = Token( - contractAddress = token.tokenAddress, - name = token.tokenName, - symbol = token.tokenSymbol, - decimals = token.decimals, - ), - network = network, - ) - }.toSet() + val networkBlockchain = Blockchain.fromNetworkId(network.backendId) + ?: error("Cannot determine blockchain for network id: ${network.backendId}") + val supportedTokens = supportedTokensData.result.tokens + .filter { + it.chainId == networkBlockchain.getChainId() + } + .map { token -> + responseCryptoCurrenciesFactory.createToken( + blockchain = networkBlockchain, + sdkToken = Token( + contractAddress = token.tokenAddress, + name = token.tokenName, + symbol = token.tokenSymbol, + decimals = token.decimals, + ), + network = network, + ) + }.toSet() // update local cache - supportedTokensState.update { supportedTokens } + supportedTokensState.update { current -> + val newMap = current.toMutableMap() + newMap[network.id] = supportedTokens + newMap + } return@withContext supportedTokens } else { error("Gasless service returned unsuccessful response") diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt index 16b1683a11..44005be4c1 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt @@ -23,7 +23,7 @@ class GaslessTxDataToGaslessRequestConverter : Converter { + return either { + catch( + block = { + transactionData as? TransactionData.Uncompiled ?: error("Uncompiled transaction data required") + + val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( + userWalletId, + fee.feeTokenId, + ).getOrNull() ?: error("Token for fee not found") + + val blockchain = Blockchain.fromId(tokenForFeeStatus.currency.network.rawId) + + val gaslessTransactionData = createGaslessTransactionData( + userWalletId = userWalletId, + transactionData = transactionData, + txFee = fee, + tokenFeeStatus = tokenForFeeStatus, + ) + + val chainId = + blockchain.getChainId() ?: error("ChainId not found for blockchain ${blockchain.name}") + + val eip712Data = Eip712TypedDataBuilder.build( + gaslessTransaction = gaslessTransactionData, + chainId = chainId, + verifyingContract = transactionData.sourceAddress, + ) + + val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data) + }, + catch = { + raise(SendTransactionError.DataError(it.message)) + }, + ) + } + } + + private suspend fun createGaslessTransactionData( + userWalletId: UserWalletId, + transactionData: TransactionData.Uncompiled, + txFee: TransactionFeeExtended, + tokenFeeStatus: CryptoCurrencyStatus, + ): GaslessTransactionData { + val bigIntegerAmount = + transactionData.amount.value?.movePointRight(transactionData.amount.decimals)?.toBigInteger() + ?: error("Amount value is null") + val txData = (transactionData.extras as? EthereumTransactionExtras)?.callData ?: error("Call data required") + + val transaction = GaslessTransactionData.Transaction( + to = getDestinationAddress(transactionData), + value = bigIntegerAmount, + data = txData.data, + ) + + val tokenForFee = tokenFeeStatus.currency as? CryptoCurrency.Token + ?: error("only CryptoCurrency.Token supported for fee") + + val txFeeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency ?: error( + "only Fee.Ethereum.TokenCurrency supported for gasless fee", + ) + val fee = GaslessTransactionData.Fee( + feeToken = tokenForFee.contractAddress, + maxTokenFee = txFeeInTokenCurrency.gasLimit, + coinPriceInToken = txFeeInTokenCurrency.coinPriceInToken, + feeTransferGasLimit = txFeeInTokenCurrency.feeTransferGasLimit, + baseGas = txFeeInTokenCurrency.baseGas, + ) + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, tokenForFee.network) + ?: error("WalletManager not found for network ${tokenForFee.network.id}") + val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error( + "WalletManager for network ${tokenForFee.network.id} does not support gasless transactions", + ) + + val nonceResult = gaslessDataProvider.getGaslessContractNonce( + userAddress = transactionData.sourceAddress, + ) + val nonce = when (nonceResult) { + is com.tangem.blockchain.extensions.Result.Failure -> BigInteger.ZERO + is com.tangem.blockchain.extensions.Result.Success -> nonceResult.data + } + + return GaslessTransactionData( + transaction = transaction, + fee = fee, + nonce = nonce, + ) + } + + private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { + val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData + return if (ethereumCallData is EthereumYieldSupplySendCallData) { + ethereumCallData.destinationAddress + } else { + txData.destinationAddress + } } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt new file mode 100644 index 0000000000..6164e008db --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt @@ -0,0 +1,124 @@ +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("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("nonce", gaslessTransaction.nonce.toString()) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index 1515c315e8..f3881c6787 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -77,12 +77,17 @@ class GetAvailableFeeTokensUseCase( userCurrenciesStatuses: List, ): List { val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(network) + .mapNotNull { + (it as? CryptoCurrency.Token)?.contractAddress?.lowercase() + }.toSet() return userCurrenciesStatuses .asSequence() + .filter { it.currency.network.id == network.id } .filter { currencyStatus -> - currencyStatus.currency is CryptoCurrency.Token && + val token = currencyStatus.currency + token is CryptoCurrency.Token && currencyStatus.value.amount?.let { amount -> amount > BigDecimal.ZERO } == true && - supportedGaslessTokens.contains(currencyStatus.currency) + supportedGaslessTokens.contains(token.contractAddress.lowercase()) } .toList() } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index 84be8c0fde..a8ef3aa70b 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -151,11 +151,17 @@ class GetFeeForGaslessUseCase( 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 { it.currency in supportedGaslessTokens } + .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. @@ -171,11 +177,4 @@ class GetFeeForGaslessUseCase( initialFee = initialFee, ).bind() } - - private companion object { - /** Amount in token units for fee transfer */ - const val FEE_TRANSFER_AMOUNT = 10000 - /** Gas price safety multiplier for fee calculation */ - const val GAS_PRICE_MULTIPLIER = 2 - } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index 392aa39519..e1c07c4617 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -101,13 +101,21 @@ internal class TokenFeeCalculator( val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null") - val coinPriceInToken = nativeFiatRate.divide( + + val coinPriceInTokenInBigDecimal = nativeFiatRate.divide( tokenFiatRate, maxOf(nativeCurrencyStatus.currency.decimals, tokenForPayFee.decimals), - RoundingMode.DOWN, + RoundingMode.UP, ) - val feeInTokenCurrency = coinPriceInToken.multiply(feeInNativeCurrency.toBigDecimal()) + val coinPriceInTokenBigInt = coinPriceInTokenInBigDecimal + .movePointRight(tokenForPayFee.decimals) + .increaseByPercent(PERCENT_TO_INCREASE_TOKEN_PRICE) + .toBigInteger() + + val feeInTokenCurrency = coinPriceInTokenInBigDecimal.multiply( + feeInNativeCurrency.toBigDecimal(), + ) val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO if (tokenBalance < feeInTokenCurrency) { @@ -119,7 +127,7 @@ internal class TokenFeeCalculator( val fee = Fee.Ethereum.TokenCurrency( amount = amount, gasLimit = maxTokenFeeGas, - coinPriceInToken = coinPriceInToken, + coinPriceInToken = coinPriceInTokenBigInt, feeTransferGasLimit = feeTransferGasLimit, baseGas = baseGas, ) @@ -151,8 +159,28 @@ internal class TokenFeeCalculator( private companion object { /** Amount in token units for fee transfer */ - const val FEE_TRANSFER_AMOUNT = 10000 + const val FEE_TRANSFER_AMOUNT = 0.01 // calculate using decimals /** Gas price safety multiplier for fee calculation */ const val GAS_PRICE_MULTIPLIER = 2 + const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 + + /** + * 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) + } } } \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index b5bf92e53a..342d7d6a54 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1340" +tangemBlockchainSdk = "develop-1346" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^