Updated on 2026-08-14
This commit is contained in:
parent
7e94a484a0
commit
80e33b892a
51 changed files with 2975 additions and 445 deletions
|
|
@ -16,6 +16,7 @@ dependencies {
|
|||
implementation(tangemDeps.card.core)
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,23 @@ package com.tangem.data.transaction
|
|||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
|
||||
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
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.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -23,17 +29,19 @@ import java.math.BigInteger
|
|||
|
||||
class DefaultGaslessTransactionRepository(
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
private val isGaslessV2Enabled: Boolean,
|
||||
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : GaslessTransactionRepository {
|
||||
|
||||
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
|
||||
private val allFeeRecipientAddress = mutableSetOf<String>()
|
||||
private val allAddressesMutex = Mutex()
|
||||
private val receiverAddressMutex = Mutex()
|
||||
private var feeReceiverAddress: String? = null
|
||||
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
|
||||
private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled)
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter)
|
||||
private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter)
|
||||
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
|
||||
|
||||
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
||||
|
|
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
|
|||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
|
||||
val response = if (isGaslessV2Enabled) {
|
||||
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
|
||||
} else {
|
||||
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
|
||||
}.getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
|
|
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
|
|||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = network.toBlockchain()
|
||||
val transactionRequest = gaslessBatchTransactionRequestBuilder.build(
|
||||
gaslessBatchTransaction = gaslessBatchTransactionData,
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
|
||||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
}
|
||||
|
||||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
|
|||
}
|
||||
|
||||
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
||||
return allAddressesMutex.withLock {
|
||||
allFeeRecipientAddress.ifEmpty {
|
||||
val allFeeAddresses = getAllFeeRecipientAddresses()
|
||||
allFeeRecipientAddress.addAll(allFeeAddresses)
|
||||
allFeeRecipientAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
|
||||
// TODO Replace with other backend call to get all fee recipient addresses when available
|
||||
return setOf(getTokenFeeReceiverAddress())
|
||||
val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() }
|
||||
.onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) }
|
||||
.getOrNull()
|
||||
return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
|
||||
|
||||
|
||||
val KNOWN_FEE_COLLECTION_ADDRESSES = setOf(
|
||||
"0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c",
|
||||
"0xAf722F46145fbb106379d506ED3a5B96f110c8E5",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ 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.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import java.math.BigInteger
|
||||
|
|
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
|
|||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
|
||||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converts domain [Eip7702Authorization] to its DTO representation.
|
||||
* Shared by both single-transaction and batch-transaction request builders.
|
||||
*/
|
||||
class Eip7702AuthorizationConverter : Converter<Eip7702Authorization, Eip7702AuthorizationDTO> {
|
||||
|
||||
override fun convert(value: Eip7702Authorization): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = value.chainId,
|
||||
address = value.address,
|
||||
nonce = value.nonce.toString(),
|
||||
yParity = value.yParity,
|
||||
r = value.r,
|
||||
s = value.s,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionDataDTO
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
|
||||
/**
|
||||
* Builder for creating complete [GaslessBatchTransactionRequest] from domain model.
|
||||
* Combines batch transaction data with signature and user information.
|
||||
*
|
||||
* Reuses [GaslessTxDataToGaslessRequestConverter] for transaction and fee conversion
|
||||
* to avoid duplicating mapping logic.
|
||||
*/
|
||||
class GaslessBatchTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Creates complete gasless batch transaction request.
|
||||
*
|
||||
* @param gaslessBatchTransaction domain model of batch 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(
|
||||
gaslessBatchTransaction: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
chainId: Int,
|
||||
eip7702Auth: Eip7702Authorization? = null,
|
||||
): GaslessBatchTransactionRequest {
|
||||
return GaslessBatchTransactionRequest(
|
||||
gaslessTransaction = GaslessBatchTransactionDataDTO(
|
||||
transactions = gaslessBatchTransaction.transactions.map { converter.convertTransaction(it) },
|
||||
fee = converter.convertFee(gaslessBatchTransaction.fee),
|
||||
nonce = gaslessBatchTransaction.nonce.toString(),
|
||||
),
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ 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.
|
||||
|
|
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
|||
*/
|
||||
class GaslessTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
|
|||
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,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
|
|||
* Note: This converter only handles the transaction data conversion.
|
||||
* Additional fields (signature, userAddress, chainId) must be added separately
|
||||
* to create complete GaslessTransactionRequest.
|
||||
*
|
||||
* @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the
|
||||
* request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed.
|
||||
*/
|
||||
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
class GaslessTxDataToGaslessRequestConverter(
|
||||
private val shouldIncludeGasLimit: Boolean = true,
|
||||
) : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
|
||||
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
|
||||
return GaslessTransactionDataDTO(
|
||||
|
|
@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
|
|||
)
|
||||
}
|
||||
|
||||
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
internal fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
return TransactionData(
|
||||
to = transaction.to,
|
||||
value = transaction.value.toString(),
|
||||
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
|
||||
data = transaction.data.toHexString().formatHex(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
return FeeData(
|
||||
feeToken = fee.feeToken,
|
||||
maxTokenFee = fee.maxTokenFee.toString(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
|||
import com.tangem.data.transaction.*
|
||||
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
|
||||
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
|
|
@ -84,10 +87,17 @@ internal object TransactionDataModule {
|
|||
fun provideGaslessTransactionRepository(
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
): GaslessTransactionRepository {
|
||||
return DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
// Single master toggle for the whole gasless v2 protocol (+ yield-withdraw batch).
|
||||
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||
),
|
||||
coroutineDispatcherProvider = coroutineDispatcherProvider,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
package com.tangem.data.transaction
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.gasless.models.GaslessFeeRecipient
|
||||
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class DefaultGaslessTransactionRepositoryTest {
|
||||
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi = mockk()
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2 = mockk()
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(gaslessTxServiceApi, gaslessTxServiceApiV2, responseCryptoCurrenciesFactory)
|
||||
}
|
||||
|
||||
private fun createRepository() = DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
isGaslessV2Enabled = true,
|
||||
coroutineDispatcherProvider = TestingCoroutineDispatcherProvider(),
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
||||
private fun stubFeeRecipientSuccess(address: String) {
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns ApiResponse.Success(
|
||||
data = GaslessServiceResponse(
|
||||
result = GaslessFeeRecipient(address = address),
|
||||
isSuccess = true,
|
||||
timestamp = "2026-06-11T00:00:00.000Z",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun stubFeeRecipientFailure() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val error = ApiResponse.Error(cause = ApiResponseError.NetworkException())
|
||||
as ApiResponse<GaslessServiceResponse<GaslessFeeRecipient>>
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns error
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend returns recipient WHEN getGaslessFeeAddresses THEN hardcoded plus backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails WHEN getGaslessFeeAddresses THEN hardcoded addresses only`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails then recovers WHEN called twice THEN second call includes backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
val firstResult = repository.getGaslessFeeAddresses()
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
|
||||
// Act
|
||||
val secondResult = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(firstResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
assertThat(secondResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend succeeds WHEN called twice THEN fee recipient requested once`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
repository.getGaslessFeeAddresses()
|
||||
repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { gaslessTxServiceApi.getFeeRecipient() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HARDCODED_ADDRESS_1 = "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c"
|
||||
const val HARDCODED_ADDRESS_2 = "0xAf722F46145fbb106379d506ED3a5B96f110c8E5"
|
||||
const val BACKEND_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigInteger
|
||||
|
||||
class GaslessBatchTransactionRequestBuilderTest {
|
||||
|
||||
private val builder = GaslessBatchTransactionRequestBuilder()
|
||||
|
||||
// byteArrayOf(0x12, 0x34).toHexString() == "1234" (uppercase), .formatHex() prepends "0x" → "0x1234"
|
||||
private val tx1Data = byteArrayOf(0x12, 0x34)
|
||||
private val tx1DataHex = "0x1234"
|
||||
|
||||
// byteArrayOf(0xAB.toByte(), 0xCD.toByte()).toHexString() == "ABCD", .formatHex() → "0xABCD"
|
||||
private val tx2Data = byteArrayOf(0xAB.toByte(), 0xCD.toByte())
|
||||
private val tx2DataHex = "0xABCD"
|
||||
|
||||
private val tx1 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractA",
|
||||
value = BigInteger("100"),
|
||||
gasLimit = BigInteger("120000"),
|
||||
data = tx1Data,
|
||||
)
|
||||
private val tx2 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractB",
|
||||
value = BigInteger("0"),
|
||||
gasLimit = BigInteger("150000"),
|
||||
data = tx2Data,
|
||||
)
|
||||
private val fee = GaslessTransactionData.Fee(
|
||||
feeToken = "0xFeeToken",
|
||||
maxTokenFee = BigInteger("500"),
|
||||
coinPriceInToken = BigInteger("200"),
|
||||
feeTransferGasLimit = BigInteger("21000"),
|
||||
baseGas = BigInteger("60000"),
|
||||
feeReceiver = "0xFeeReceiver",
|
||||
)
|
||||
private val nonce = BigInteger("42")
|
||||
|
||||
private val batchData = GaslessBatchTransactionData(
|
||||
transactions = listOf(tx1, tx2),
|
||||
fee = fee,
|
||||
nonce = nonce,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `build - transactions list has correct size and order`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.transactions).hasSize(2)
|
||||
assertThat(result.gaslessTransaction.transactions[0].to).isEqualTo("0xContractA")
|
||||
assertThat(result.gaslessTransaction.transactions[1].to).isEqualTo("0xContractB")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - transaction data fields are encoded correctly`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
// data bytes are hex-encoded with 0x prefix (uppercase)
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
assertThat(txDtoList[1].data).isEqualTo(tx2DataHex)
|
||||
// value is BigInteger.toString()
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[1].value).isEqualTo("0")
|
||||
// v2: per-call gasLimit is BigInteger.toString()
|
||||
assertThat(txDtoList[0].gasLimit).isEqualTo("120000")
|
||||
assertThat(txDtoList[1].gasLimit).isEqualTo("150000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - v1 converter omits per-call gasLimit`() {
|
||||
// Arrange: a builder whose converter is in v1 mode (shouldIncludeGasLimit = false)
|
||||
val v1Builder = GaslessBatchTransactionRequestBuilder(
|
||||
converter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = false),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = v1Builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
// Assert: gasLimit is null so Moshi omits it, restoring the legacy v1 {to, value, data} shape
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
assertThat(txDtoList[0].gasLimit).isNull()
|
||||
assertThat(txDtoList[1].gasLimit).isNull()
|
||||
// other fields are unaffected
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - fee fields are all toString of BigInteger inputs`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val feeDto = result.gaslessTransaction.fee
|
||||
assertThat(feeDto.feeToken).isEqualTo("0xFeeToken")
|
||||
assertThat(feeDto.maxTokenFee).isEqualTo("500")
|
||||
assertThat(feeDto.coinPriceInToken).isEqualTo("200")
|
||||
assertThat(feeDto.feeTransferGasLimit).isEqualTo("21000")
|
||||
assertThat(feeDto.baseGas).isEqualTo("60000")
|
||||
assertThat(feeDto.feeReceiver).isEqualTo("0xFeeReceiver")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - nonce is toString of BigInteger input`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.nonce).isEqualTo("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - top-level signature, userAddress, chainId pass through`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xDeadBeef",
|
||||
userAddress = "0xAlice",
|
||||
chainId = 137,
|
||||
)
|
||||
|
||||
assertThat(result.signature).isEqualTo("0xDeadBeef")
|
||||
assertThat(result.userAddress).isEqualTo("0xAlice")
|
||||
assertThat(result.chainId).isEqualTo(137)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth is null when not provided`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.eip7702Auth).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth maps correctly when provided`() {
|
||||
val auth = Eip7702Authorization(
|
||||
chainId = 1,
|
||||
address = "0xEntryPoint",
|
||||
nonce = BigInteger("7"),
|
||||
yParity = 0,
|
||||
r = "0xRValue",
|
||||
s = "0xSValue",
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
eip7702Auth = auth,
|
||||
)
|
||||
|
||||
val authDto = result.eip7702Auth
|
||||
assertThat(authDto).isNotNull()
|
||||
assertThat(authDto!!.chainId).isEqualTo(1)
|
||||
assertThat(authDto.address).isEqualTo("0xEntryPoint")
|
||||
assertThat(authDto.nonce).isEqualTo("7")
|
||||
assertThat(authDto.yParity).isEqualTo(0)
|
||||
assertThat(authDto.r).isEqualTo("0xRValue")
|
||||
assertThat(authDto.s).isEqualTo("0xSValue")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue