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 7a4a698b9e..40677f16b3 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 @@ -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>>(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( diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index 9ad092d702..7ac662e4ce 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -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, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 84074cf002..1ebe8d1635 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -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 } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index f80df07707..fde2117248 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -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 { - return either { - catch( - block = { - transactionData as? TransactionData.Uncompiled ?: error("Uncompiled transaction data required") + ): Either = 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() + } + } } \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 342d7d6a54..f9c9c8e39f 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-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 ^