Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-08 14:47:09 +03:00
parent 98e7136b42
commit 61b848204f
5 changed files with 326 additions and 87 deletions

View file

@ -2,7 +2,6 @@ package com.tangem.data.transaction
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
@ -27,11 +26,12 @@ class DefaultGaslessTransactionRepository(
) : GaslessTransactionRepository {
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
override fun isNetworkSupported(network: Network): Boolean {
val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false
val blockchain = Blockchain.fromId(network.rawId)
return SUPPORTED_BLOCKCHAINS.contains(blockchain)
}
@ -44,8 +44,7 @@ class DefaultGaslessTransactionRepository(
val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow()
if (supportedTokensData.isSuccess) {
val networkBlockchain = Blockchain.fromNetworkId(network.backendId)
?: error("Cannot determine blockchain for network id: ${network.backendId}")
val networkBlockchain = Blockchain.fromId(network.rawId)
val supportedTokens = supportedTokensData.result.tokens
.filter {
it.chainId == networkBlockchain.getChainId()
@ -79,16 +78,14 @@ class DefaultGaslessTransactionRepository(
return TOKEN_RECEIVER_ADDRESS
}
override suspend fun sendGaslessTransaction(
override suspend fun signGaslessTransaction(
gaslessTransactionData: GaslessTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization?,
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromNetworkId(network.backendId)
?: error("Cannot determine blockchain for network id: ${network.backendId}")
val blockchain = Blockchain.fromId(network.rawId)
val transactionRequest = gaslessTransactionRequestBuilder.build(
gaslessTransaction = gaslessTransactionData,
signature = signature,
@ -111,9 +108,14 @@ class DefaultGaslessTransactionRepository(
return BASE_GAS_FOR_TRANSACTION
}
override fun getChainIdForNetwork(network: Network): Int {
val networkBlockchain = Blockchain.fromId(network.rawId)
return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}")
}
private companion object {
const val TOKEN_RECEIVER_ADDRESS = "0x"
const val TOKEN_RECEIVER_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000")
val SUPPORTED_BLOCKCHAINS = arrayOf(

View file

@ -2,7 +2,6 @@ package com.tangem.data.transaction
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -17,7 +16,7 @@ class MockedGaslessTransactionRepository(
) : GaslessTransactionRepository {
override fun isNetworkSupported(network: Network): Boolean {
val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false
val blockchain = Blockchain.fromId(network.rawId)
return SUPPORTED_BLOCKCHAINS.contains(blockchain)
}
@ -35,11 +34,16 @@ class MockedGaslessTransactionRepository(
return setOf(usdcPolygon)
}
override fun getChainIdForNetwork(network: Network): Int {
val networkBlockchain = Blockchain.fromId(network.rawId)
return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}")
}
override fun getTokenFeeReceiverAddress(): String {
return TOKEN_RECEIVER_ADDRESS
}
override suspend fun sendGaslessTransaction(
override suspend fun signGaslessTransaction(
gaslessTransactionData: GaslessTransactionData,
signature: String,
userAddress: String,

View file

@ -51,7 +51,7 @@ interface GaslessTransactionRepository {
* @throws IllegalStateException if network is not supported or chainId cannot be determined
* @throws Exception if service returns error or network request fails
*/
suspend fun sendGaslessTransaction(
suspend fun signGaslessTransaction(
gaslessTransactionData: GaslessTransactionData,
signature: String,
userAddress: String,
@ -63,4 +63,6 @@ interface GaslessTransactionRepository {
* Hardcoded value as baseGas
*/
fun getBaseGasForTransaction(): BigInteger
fun getChainIdForNetwork(network: Network): Int
}

View file

@ -6,15 +6,30 @@ 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.blockchains.ethereum.models.EIP7702AuthorizationData
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.formatHex
import com.tangem.blockchain.extensions.removeLeadingZero
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.UserWalletId
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.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -23,95 +38,219 @@ 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,
) {
@Suppress("UnusedPrivateProperty")
suspend operator fun invoke(
userWalletId: UserWalletId,
userWallet: UserWallet,
transactionData: TransactionData,
fee: TransactionFeeExtended,
): Either<SendTransactionError, Unit> {
return either {
catch(
block = {
transactionData as? TransactionData.Uncompiled ?: error("Uncompiled transaction data required")
): Either<SendTransactionError, Unit> = either {
catch(
block = {
val uncompiledTxData = validateTransactionData(transactionData)
val context = prepareGaslessContext(userWallet, uncompiledTxData, fee)
val signedData = signGaslessTransactionByUser(userWallet, context, uncompiledTxData)
val remoteSignedData = signTransactionOnBackend(context, signedData, uncompiledTxData)
broadcastSignedTransaction(remoteSignedData)
},
catch = {
raise(SendTransactionError.DataError(it.message))
},
)
}
val tokenForFeeStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWalletId,
fee.feeTokenId,
).getOrNull() ?: error("Token for fee not found")
/**
* 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")
}
val blockchain = Blockchain.fromId(tokenForFeeStatus.currency.network.rawId)
/**
* 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 gaslessTransactionData = createGaslessTransactionData(
userWalletId = userWalletId,
transactionData = transactionData,
txFee = fee,
tokenFeeStatus = tokenForFeeStatus,
)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWallet.walletId,
tokenForFeeStatus.currency.network,
) ?: error("WalletManager not found for network ${tokenForFeeStatus.currency.network.id}")
val chainId =
blockchain.getChainId() ?: error("ChainId not found for blockchain ${blockchain.name}")
val gaslessDataProvider = walletManager as? EthereumGaslessDataProvider ?: error(
"WalletManager for network ${tokenForFeeStatus.currency.network.id} " +
"does not support gasless transactions",
)
val eip712Data = Eip712TypedDataBuilder.build(
gaslessTransaction = gaslessTransactionData,
chainId = chainId,
verifyingContract = transactionData.sourceAddress,
)
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
},
catch = {
raise(SendTransactionError.DataError(it.message))
},
)
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
}
}
private suspend fun createGaslessTransactionData(
userWalletId: UserWalletId,
/**
* 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 = transactionData.sourceAddress,
nonce = eip7702Data.nonce,
yParity = extendedEip7702Data.recId,
r = extendedEip7702Data.r.toFormattedHex(),
s = extendedEip7702Data.s.toFormattedHex(),
)
return SignedGaslessData(
eip712Signature = preparedEip712Hash,
eip7702Auth = eip7702Auth,
)
}
/**
* Sends gasless transaction to the service.
*/
private suspend fun signTransactionOnBackend(
context: GaslessContext,
signedData: SignedGaslessData,
transactionData: TransactionData.Uncompiled,
): GaslessSignedTransactionResult {
return gaslessTransactionRepository.signGaslessTransaction(
network = context.tokenForFeeStatus.currency.network,
gaslessTransactionData = context.gaslessTransactionData,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
)
}
@Suppress("UnusedParameter")
private fun broadcastSignedTransaction(remoteSignedData: GaslessSignedTransactionResult) {
TODO("Will be implemented later")
}
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 fun createGaslessTransactionData(
transactionData: TransactionData.Uncompiled,
txFee: TransactionFeeExtended,
tokenFeeStatus: CryptoCurrencyStatus,
nonce: BigInteger,
): 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
}
val transaction = buildTransaction(transactionData)
val fee = buildFee(txFee, tokenFeeStatus)
return GaslessTransactionData(
transaction = transaction,
@ -120,6 +259,50 @@ class CreateAndSendGaslessTransactionUseCase(
)
}
private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction {
val amount = transactionData.amount.value
?.movePointRight(transactionData.amount.decimals)
?.toBigInteger()
?: error("Transaction amount value is null")
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
?: error("Ethereum call data is required")
return GaslessTransactionData.Transaction(
to = getDestinationAddress(transactionData),
value = amount,
data = callData.data,
)
}
private 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,
)
}
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
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
@ -128,4 +311,52 @@ class CreateAndSendGaslessTransactionUseCase(
txData.destinationAddress
}
}
/**
* 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(): String {
return toByteArray().removeLeadingZero().toHexString().formatHex()
}
}
}

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1346"
tangemBlockchainSdk = "develop-1352"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-573"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^