Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-26 15:48:05 +04:00
parent 7e94a484a0
commit 80e33b892a
51 changed files with 2975 additions and 445 deletions

View file

@ -16,6 +16,7 @@ dependencies {
implementation(tangemDeps.card.core)
/** Core */
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.utils)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.core.analytics)
/** Domain */
implementation(projects.domain.transaction)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.walletManager)

View file

@ -8,6 +8,7 @@ import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -238,6 +239,37 @@ internal class DefaultYieldSupplyTransactionRepository(
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
}
override suspend fun getYieldModuleVersionStatus(
userWalletId: UserWalletId,
network: Network,
): YieldModuleVersionStatus = withContext(dispatchers.io) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = network.toBlockchain(),
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found for $network")
walletManager.checkModuleVersionStatus()
}
override suspend fun createPartialWithdrawCallData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
amount: Amount,
): SmartContractCallData = withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
val withdrawCallData = YieldSupplyContractCallDataProviderFactory.getWithdrawCallData(
tokenContractAddress = cryptoCurrency.contractAddress,
amount = amount,
)
val versionStatus = walletManager.checkModuleVersionStatus()
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, withdrawCallData)
}
private suspend fun getYieldTokenStatus(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,

View file

@ -12,6 +12,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.domain.yield.supply.YieldSupplyRepository
@ -41,6 +42,14 @@ internal object YieldSupplyDataModule {
)
}
@Provides
@Singleton
fun provideGaslessYieldRepository(
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
): GaslessYieldRepository {
return yieldSupplyTransactionRepository
}
@Provides
@Singleton
fun provideYieldSupplyMarketRepository(

View file

@ -3,11 +3,14 @@ package com.tangem.data.yield.supply
import com.google.common.truth.Truth
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -306,4 +309,35 @@ class DefaultYieldSupplyTransactionRepositoryTest {
Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java)
Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data)
}
@Test
fun `createPartialWithdrawCallData returns withdraw call data when module is up to date`() = runTest {
coEvery { walletManager.checkModuleVersionStatus() } returns YieldModuleVersionStatus.UpToDate
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
every { contractAddress } returns mockedContractAddress
every { decimals } returns 6
}
val amount = Amount(
currencySymbol = "USDC",
value = BigDecimal("1.5"),
decimals = 6,
type = AmountType.Token(
token = Token(
symbol = "USDC",
contractAddress = mockedContractAddress,
decimals = 6,
),
),
)
val result = repository.createPartialWithdrawCallData(
userWalletId = userWalletId,
cryptoCurrency = token,
amount = amount,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result.methodId).isEqualTo("0xf3fef3a3")
}
}