Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-06 13:15:40 +03:00
parent 7c92156d19
commit 680794d447
12 changed files with 357 additions and 5 deletions

View file

@ -2,7 +2,7 @@ package com.tangem.datasource.api.gasless
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResult
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO
import com.tangem.datasource.api.gasless.models.GaslessSupportedTokens
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import retrofit2.http.Body
@ -17,5 +17,5 @@ interface GaslessTxServiceApi {
@POST("api/v1/sign")
suspend fun signGaslessTransaction(
@Body transaction: GaslessTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResult>>
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
}

View file

@ -8,7 +8,7 @@ import com.squareup.moshi.JsonClass
* Contains the signed transaction data and gas parameters.
*/
@JsonClass(generateAdapter = true)
data class GaslessSignedTransactionResult(
data class GaslessSignedTransactionResultDTO(
@Json(name = "signedTransaction")
val signedTransaction: String,

View file

@ -22,7 +22,7 @@ data class GaslessTransactionRequest(
val chainId: Int,
@Json(name = "eip7702auth")
val eip7702Auth: Eip7702Authorization? = null,
val eip7702Auth: Eip7702AuthorizationDTO? = null,
)
@JsonClass(generateAdapter = true)
@ -72,7 +72,7 @@ data class FeeData(
* Optional field, used only when EOA delegation is required.
*/
@JsonClass(generateAdapter = true)
data class Eip7702Authorization(
data class Eip7702AuthorizationDTO(
@Json(name = "chainId")
val chainId: Int,

View file

@ -4,11 +4,16 @@ 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
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -22,6 +27,8 @@ class DefaultGaslessTransactionRepository(
) : GaslessTransactionRepository {
private val supportedTokensState = MutableStateFlow<Set<CryptoCurrency>?>(null)
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
override fun isNetworkSupported(network: Network): Boolean {
val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false
@ -63,6 +70,34 @@ class DefaultGaslessTransactionRepository(
return TOKEN_RECEIVER_ADDRESS
}
override suspend fun sendGaslessTransaction(
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 transactionRequest = gaslessTransactionRequestBuilder.build(
gaslessTransaction = gaslessTransactionData,
signature = signature,
userAddress = userAddress,
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
eip7702Auth = eip7702Auth,
)
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
if (!response.isSuccess) {
error("Gasless service returned unsuccessful response")
}
// Convert DTO to domain model
signedTransactionResultConverter.convert(response.result)
}
override fun getBaseGasForTransaction(): BigInteger {
return BASE_GAS_FOR_TRANSACTION
}

View file

@ -0,0 +1,22 @@
package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO as GaslessSignedTransactionResultDTO
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
import com.tangem.utils.converter.Converter
/**
* Converts DTO GaslessSignedTransactionResult from API to domain model.
* Transforms string representations of gas parameters to BigInteger for type safety.
*/
class GaslessSignedTransactionResultConverter :
Converter<GaslessSignedTransactionResultDTO, GaslessSignedTransactionResult> {
override fun convert(value: GaslessSignedTransactionResultDTO): GaslessSignedTransactionResult {
return GaslessSignedTransactionResult(
signedTransaction = value.signedTransaction,
gasLimit = value.gasLimit.toBigInteger(),
maxFeePerGas = value.maxFeePerGas.toBigInteger(),
maxPriorityFeePerGas = value.maxPriorityFeePerGas.toBigInteger(),
)
}
}

View file

@ -0,0 +1,55 @@
package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
/**
* Builder for creating complete GaslessTransactionRequest from domain model.
* Combines transaction data with signature and user information.
*/
class GaslessTransactionRequestBuilder(
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
) {
/**
* Creates complete gasless transaction request.
*
* @param gaslessTransaction domain model of transaction
* @param signature transaction signature in hex format (with 0x prefix)
* @param userAddress user's Ethereum address
* @param chainId blockchain network chain ID
* @param eip7702Auth optional EIP-7702 authorization for account abstraction
* @return complete request ready for API submission
*/
fun build(
gaslessTransaction: GaslessTransactionData,
signature: String,
userAddress: String,
chainId: Int,
eip7702Auth: Eip7702Authorization? = null,
): GaslessTransactionRequest {
return GaslessTransactionRequest(
gaslessTransaction = converter.convert(gaslessTransaction),
signature = signature,
userAddress = userAddress,
chainId = chainId,
eip7702Auth = eip7702Auth?.toDTO(),
)
}
/**
* Converts domain Eip7702Authorization to DTO.
*/
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
return Eip7702AuthorizationDTO(
chainId = chainId,
address = address,
nonce = nonce.toString(),
yParity = yParity,
r = r,
s = s,
)
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.FeeData
import com.tangem.datasource.api.gasless.models.TransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.toHexString
import com.tangem.datasource.api.gasless.models.GaslessTransactionData as GaslessTransactionDataDTO
/**
* Converts domain GaslessTransactionData to DTO for API requests.
* Note: This converter only handles the transaction data conversion.
* Additional fields (signature, userAddress, chainId) must be added separately
* to create complete GaslessTransactionRequest.
*/
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
return GaslessTransactionDataDTO(
transaction = convertTransaction(value.transaction),
fee = convertFee(value.fee),
nonce = value.nonce.toString(),
)
}
private fun convertTransaction(transaction: com.tangem.domain.transaction.models.Transaction): TransactionData {
return TransactionData(
to = transaction.to,
value = transaction.value.toString(),
data = transaction.data.toHexString(),
)
}
private fun convertFee(fee: com.tangem.domain.transaction.models.Fee): FeeData {
return FeeData(
feeToken = fee.feeToken,
maxTokenFee = fee.maxTokenFee.toString(),
coinPriceInToken = fee.coinPriceInToken.toString(),
feeTransferGasLimit = fee.feeTransferGasLimit.toString(),
baseGas = fee.baseGas.toString(),
)
}
}

View file

@ -2,6 +2,9 @@ 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 {
@ -12,6 +15,50 @@ interface GaslessTransactionRepository {
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 sendGaslessTransaction(
gaslessTransactionData: GaslessTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult
/**
* Hardcoded value as baseGas
*/

View file

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

View file

@ -0,0 +1,37 @@
package com.tangem.domain.transaction.models
import java.math.BigInteger
/**
* 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 signedTransaction complete signed transaction in RLP-encoded hex format (0x...)
* ready to be broadcast to the blockchain network
* @property gasLimit maximum amount of gas units allocated for the transaction
* @property maxFeePerGas maximum total fee per gas unit (base fee + priority fee) in wei
* @property maxPriorityFeePerGas maximum priority fee (tip) per gas unit in wei for EIP-1559
*/
data class GaslessSignedTransactionResult(
val signedTransaction: String,
val gasLimit: BigInteger,
val maxFeePerGas: BigInteger,
val maxPriorityFeePerGas: BigInteger,
) {
init {
require(signedTransaction.isNotBlank()) { "Signed transaction must not be blank" }
require(signedTransaction.startsWith("0x")) { "Signed transaction must be in hex format with 0x prefix" }
require(gasLimit > BigInteger.ZERO) { "Gas limit must be positive" }
require(maxFeePerGas > BigInteger.ZERO) { "Max fee per gas must be positive" }
require(maxPriorityFeePerGas >= BigInteger.ZERO) { "Max priority fee per gas must be non-negative" }
require(maxPriorityFeePerGas <= maxFeePerGas) {
"Max priority fee per gas cannot exceed max fee per gas"
}
}
}

View file

@ -0,0 +1,66 @@
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)
*/
data class Fee(
val feeToken: String,
val maxTokenFee: BigInteger,
val coinPriceInToken: BigInteger,
val feeTransferGasLimit: BigInteger,
val baseGas: BigInteger,
)

View file

@ -0,0 +1,11 @@
package com.tangem.domain.transaction.usecase.gasless
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended
class CreateAndSendGaslessTransactionUseCase {
operator fun invoke(transactionData: TransactionData, fee: TransactionFeeExtended) {
// Implementation goes here
}
}