Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-07 14:23:34 +03:00
parent 3baec6cc7e
commit 8564f21d01
9 changed files with 369 additions and 83 deletions

View file

@ -26,7 +26,7 @@ class DefaultGaslessTransactionRepository(
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
) : GaslessTransactionRepository {
private val supportedTokensState = MutableStateFlow<Set<CryptoCurrency>?>(null)
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
@ -37,28 +37,37 @@ class DefaultGaslessTransactionRepository(
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
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")

View file

@ -23,7 +23,7 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
)
}
private fun convertTransaction(transaction: com.tangem.domain.transaction.models.Transaction): TransactionData {
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
return TransactionData(
to = transaction.to,
value = transaction.value.toString(),
@ -31,7 +31,7 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
)
}
private fun convertFee(fee: com.tangem.domain.transaction.models.Fee): FeeData {
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
return FeeData(
feeToken = fee.feeToken,
maxTokenFee = fee.maxTokenFee.toString(),

View file

@ -13,54 +13,55 @@ data class GaslessTransactionData(
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
/**
* 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
if (to != other.to) return false
if (value != other.value) return false
if (!data.contentEquals(other.data)) return false
other as Transaction
return true
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
}
}
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)
*/
data class Fee(
val feeToken: String,
val maxTokenFee: BigInteger,
val coinPriceInToken: BigInteger,
val feeTransferGasLimit: BigInteger,
val baseGas: BigInteger,
)
/**
* 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)
*/
data class Fee(
val feeToken: String,
val maxTokenFee: BigInteger,
val coinPriceInToken: BigInteger,
val feeTransferGasLimit: BigInteger,
val baseGas: BigInteger,
)
}

View file

@ -1,11 +1,131 @@
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.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.transaction.error.SendTransactionError
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 {
class CreateAndSendGaslessTransactionUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
) {
operator fun invoke(transactionData: TransactionData, fee: TransactionFeeExtended) {
// Implementation goes here
@Suppress("UnusedPrivateProperty")
suspend operator fun invoke(
userWalletId: UserWalletId,
transactionData: TransactionData,
fee: TransactionFeeExtended,
): Either<SendTransactionError, Unit> {
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
}
}
}

View file

@ -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())
}
}
}

View file

@ -77,12 +77,17 @@ class GetAvailableFeeTokensUseCase(
userCurrenciesStatuses: List<CryptoCurrencyStatus>,
): List<CryptoCurrencyStatus> {
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()
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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 ^