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

@ -6,9 +6,13 @@ import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.demo.models.DemoConfig
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.notifications.repository.PushNotificationsRepository import com.tangem.domain.notifications.repository.PushNotificationsRepository
@ -319,30 +323,50 @@ internal object TransactionDomainModule {
gaslessTransactionRepository: GaslessTransactionRepository, gaslessTransactionRepository: GaslessTransactionRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier, singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository, currencyChecksRepository: CurrencyChecksRepository,
featureTogglesManager: FeatureTogglesManager,
): GetAvailableFeeTokensUseCase { ): GetAvailableFeeTokensUseCase {
return GetAvailableFeeTokensUseCase( return GetAvailableFeeTokensUseCase(
singleAccountStatusListSupplier = singleAccountStatusListSupplier, singleAccountStatusListSupplier = singleAccountStatusListSupplier,
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
currencyChecksRepository = currencyChecksRepository, currencyChecksRepository = currencyChecksRepository,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
) )
} }
@Provides
@Singleton
fun provideResolveGaslessFeePlanUseCase(
gaslessYieldRepository: GaslessYieldRepository,
): ResolveGaslessFeePlanUseCase {
return ResolveGaslessFeePlanUseCase(gaslessYieldRepository = gaslessYieldRepository)
}
@Provides @Provides
@Singleton @Singleton
fun provideGetFeeForGaslessUseCase( fun provideGetFeeForGaslessUseCase(
walletManagersFacade: WalletManagersFacade, walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository, gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
getFeeUseCase: GetFeeUseCase, getFeeUseCase: GetFeeUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier, singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository, currencyChecksRepository: CurrencyChecksRepository,
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
featureTogglesManager: FeatureTogglesManager,
): GetFeeForGaslessUseCase { ): GetFeeForGaslessUseCase {
return GetFeeForGaslessUseCase( return GetFeeForGaslessUseCase(
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig, demoConfig = DemoConfig,
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
singleAccountStatusListSupplier = singleAccountStatusListSupplier, singleAccountStatusListSupplier = singleAccountStatusListSupplier,
getFeeUseCase = getFeeUseCase, getFeeUseCase = getFeeUseCase,
currencyChecksRepository = currencyChecksRepository, currencyChecksRepository = currencyChecksRepository,
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
) )
} }
@ -351,15 +375,23 @@ internal object TransactionDomainModule {
fun provideGetFeeForTokenUseCase( fun provideGetFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade, walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository, gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier, singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository, currencyChecksRepository: CurrencyChecksRepository,
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
featureTogglesManager: FeatureTogglesManager,
): GetFeeForTokenUseCase { ): GetFeeForTokenUseCase {
return GetFeeForTokenUseCase( return GetFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig, demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier, singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository, currencyChecksRepository = currencyChecksRepository,
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
) )
} }
@ -379,6 +411,7 @@ internal object TransactionDomainModule {
singleAccountListSupplier: SingleAccountListSupplier, singleAccountListSupplier: SingleAccountListSupplier,
cardSdkConfigRepository: CardSdkConfigRepository, cardSdkConfigRepository: CardSdkConfigRepository,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
featureTogglesManager: FeatureTogglesManager,
): CreateAndSendGaslessTransactionUseCase { ): CreateAndSendGaslessTransactionUseCase {
return CreateAndSendGaslessTransactionUseCase( return CreateAndSendGaslessTransactionUseCase(
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
@ -386,6 +419,9 @@ internal object TransactionDomainModule {
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
cardSdkConfigRepository = cardSdkConfigRepository, cardSdkConfigRepository = cardSdkConfigRepository,
getHotWalletSigner = tangemHotWalletSignerFactory::create, getHotWalletSigner = tangemHotWalletSignerFactory::create,
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
) )
} }
@ -394,15 +430,21 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForTokenUseCase( fun provideEstimateFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade, walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository, gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier, singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository, currencyChecksRepository: CurrencyChecksRepository,
featureTogglesManager: FeatureTogglesManager,
): EstimateFeeForTokenUseCase { ): EstimateFeeForTokenUseCase {
return EstimateFeeForTokenUseCase( return EstimateFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig, demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier, singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository, currencyChecksRepository = currencyChecksRepository,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
) )
} }
@ -411,12 +453,14 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForGaslessTxUseCase( fun provideEstimateFeeForGaslessTxUseCase(
walletManagersFacade: WalletManagersFacade, walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository, gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier, singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
estimateFeeUseCase: EstimateFeeUseCase, estimateFeeUseCase: EstimateFeeUseCase,
currencyChecksRepository: CurrencyChecksRepository, currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForGaslessTxUseCase { ): EstimateFeeForGaslessTxUseCase {
return EstimateFeeForGaslessTxUseCase( return EstimateFeeForGaslessTxUseCase(
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig, demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier, singleAccountStatusListSupplier = singleAccountStatusListSupplier,

View file

@ -151,6 +151,10 @@
"name": "TWI_83_ADDRESS_BOOK_ENABLED", "name": "TWI_83_ADDRESS_BOOK_ENABLED",
"version": "undefined" "version": "undefined"
}, },
{
"name": "AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED",
"version": "undefined"
},
{ {
"name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED", "name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED",
"version": "6.0" "version": "6.0"

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.gasless
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import retrofit2.http.Body
import retrofit2.http.POST
interface GaslessTxServiceApiV2 {
@POST("api/v2/transaction/sign")
suspend fun signGaslessTransaction(
@Body transaction: GaslessTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
@POST("api/v2/transaction/batch-sign")
suspend fun signGaslessBatchTransaction(
@Body transaction: GaslessBatchTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
}

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for gasless batch transaction submission (v2 `POST /api/v2/transaction/batch-sign`).
* Represents a batch of transactions with fee delegation metadata.
*
* The top-level payload field is `gaslessTransaction` (shared shape with single sign see
* gasless-service `BatchSignRequestDto`), carrying `transactions[]`, `fee`, `nonce`.
*/
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionRequest(
@Json(name = "gaslessTransaction")
val gaslessTransaction: GaslessBatchTransactionDataDTO,
@Json(name = "signature")
val signature: String,
@Json(name = "userAddress")
val userAddress: String,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "eip7702auth")
val eip7702Auth: Eip7702AuthorizationDTO? = null,
)
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionDataDTO(
@Json(name = "transactions")
val transactions: List<TransactionData>,
@Json(name = "fee")
val fee: FeeData,
@Json(name = "nonce")
val nonce: String,
)

View file

@ -45,6 +45,9 @@ data class TransactionData(
@Json(name = "value") @Json(name = "value")
val value: String, val value: String,
@Json(name = "gasLimit")
val gasLimit: String? = null,
@Json(name = "data") @Json(name = "data")
val data: String, val data: String,
) )

View file

@ -18,6 +18,7 @@ import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.StakeKitApi
@ -252,4 +253,20 @@ internal object NetworkModule {
), ),
) )
} }
@Provides
@Singleton
fun provideGaslessTxServiceApiV2(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApiV2 {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
readTimeoutSeconds = TIMEOUT_60_SECONDS,
writeTimeoutSeconds = TIMEOUT_60_SECONDS,
),
)
}
} }

View file

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

View file

@ -3,17 +3,23 @@ package com.tangem.data.transaction
import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory 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.GaslessSignedTransactionResultConverter
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder 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.common.response.getOrThrow
import com.tangem.datasource.api.gasless.GaslessTxServiceApi 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.currency.CryptoCurrency
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.models.Eip7702Authorization 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.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider 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.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -23,17 +29,19 @@ import java.math.BigInteger
class DefaultGaslessTransactionRepository( class DefaultGaslessTransactionRepository(
private val gaslessTxServiceApi: GaslessTxServiceApi, private val gaslessTxServiceApi: GaslessTxServiceApi,
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
private val isGaslessV2Enabled: Boolean,
private val coroutineDispatcherProvider: CoroutineDispatcherProvider, private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
) : GaslessTransactionRepository { ) : GaslessTransactionRepository {
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf()) private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
private val allFeeRecipientAddress = mutableSetOf<String>()
private val allAddressesMutex = Mutex()
private val receiverAddressMutex = Mutex() private val receiverAddressMutex = Mutex()
private var feeReceiverAddress: String? = null 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() private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> { override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
eip7702Auth = eip7702Auth, eip7702Auth = eip7702Auth,
) )
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow() val response = if (isGaslessV2Enabled) {
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
} else {
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
}.getOrThrow()
if (!response.isSuccess) { if (!response.isSuccess) {
error("Gasless service returned unsuccessful response") error("Gasless service returned unsuccessful response")
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
signedTransactionResultConverter.convert(response.result) 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 { override fun getBaseGasForTransaction(): BigInteger {
return BASE_GAS_FOR_TRANSACTION return BASE_GAS_FOR_TRANSACTION
} }
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
} }
override suspend fun getGaslessFeeAddresses(): Set<String> { 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 // 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 { private companion object {
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000") 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.models.network.Network
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.models.Eip7702Authorization 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.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData
import java.math.BigInteger import java.math.BigInteger
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
txHash = "0x000", txHash = "0x000",
) )
override suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization?,
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
txHash = "0x000",
)
override fun getBaseGasForTransaction(): BigInteger { override fun getBaseGasForTransaction(): BigInteger {
return BASE_GAS_FOR_TRANSACTION 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.datasource.api.gasless.models.GaslessTransactionRequest
import com.tangem.domain.transaction.models.Eip7702Authorization import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
/** /**
* Builder for creating complete GaslessTransactionRequest from domain model. * Builder for creating complete GaslessTransactionRequest from domain model.
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
*/ */
class GaslessTransactionRequestBuilder( class GaslessTransactionRequestBuilder(
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(), private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
) { ) {
/** /**
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
signature = signature, signature = signature,
userAddress = userAddress, userAddress = userAddress,
chainId = chainId, chainId = chainId,
eip7702Auth = eip7702Auth?.toDTO(), eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
)
}
/**
* 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

@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
* Note: This converter only handles the transaction data conversion. * Note: This converter only handles the transaction data conversion.
* Additional fields (signature, userAddress, chainId) must be added separately * Additional fields (signature, userAddress, chainId) must be added separately
* to create complete GaslessTransactionRequest. * 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 { override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
return 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( return TransactionData(
to = transaction.to, to = transaction.to,
value = transaction.value.toString(), value = transaction.value.toString(),
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
data = transaction.data.toHexString().formatHex(), data = transaction.data.toHexString().formatHex(),
) )
} }
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData { internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
return FeeData( return FeeData(
feeToken = fee.feeToken, feeToken = fee.feeToken,
maxTokenFee = fee.maxTokenFee.toString(), 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.*
import com.tangem.data.transaction.error.DefaultFeeErrorResolver import com.tangem.data.transaction.error.DefaultFeeErrorResolver
import com.tangem.blockchainsdk.BlockchainSDKFactory 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.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.demo.models.DemoConfig
@ -84,10 +87,17 @@ internal object TransactionDataModule {
fun provideGaslessTransactionRepository( fun provideGaslessTransactionRepository(
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
gaslessTxServiceApi: GaslessTxServiceApi, gaslessTxServiceApi: GaslessTxServiceApi,
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
featureTogglesManager: FeatureTogglesManager,
coroutineDispatcherProvider: CoroutineDispatcherProvider, coroutineDispatcherProvider: CoroutineDispatcherProvider,
): GaslessTransactionRepository { ): GaslessTransactionRepository {
return DefaultGaslessTransactionRepository( return DefaultGaslessTransactionRepository(
gaslessTxServiceApi = gaslessTxServiceApi, 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, coroutineDispatcherProvider = coroutineDispatcherProvider,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, 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) implementation(projects.core.analytics)
/** Domain */ /** Domain */
implementation(projects.domain.transaction)
implementation(projects.domain.yieldSupply) implementation(projects.domain.yieldSupply)
implementation(projects.domain.yieldSupply.models) implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.walletManager) 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.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -238,6 +239,37 @@ internal class DefaultYieldSupplyTransactionRepository(
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData) 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( private suspend fun getYieldTokenStatus(
walletManager: WalletManager, walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token, 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.YieldMarketsStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyRepository
@ -41,6 +42,14 @@ internal object YieldSupplyDataModule {
) )
} }
@Provides
@Singleton
fun provideGaslessYieldRepository(
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
): GaslessYieldRepository {
return yieldSupplyTransactionRepository
}
@Provides @Provides
@Singleton @Singleton
fun provideYieldSupplyMarketRepository( fun provideYieldSupplyMarketRepository(

View file

@ -3,11 +3,14 @@ package com.tangem.data.yield.supply
import com.google.common.truth.Truth import com.google.common.truth.Truth
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils 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.Blockchain
import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.WalletManager import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory 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.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network 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).isInstanceOf(EthereumTransactionExtras::class.java)
Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data) 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")
}
} }

View file

@ -251,5 +251,15 @@
"info": "GaslessTransactions", "info": "GaslessTransactions",
"source": "https://github.com/tangem-developments/tangem-gasless-service", "source": "https://github.com/tangem-developments/tangem-gasless-service",
"name": "gaslessTransaction" "name": "gaslessTransaction"
},
"0x4b072692": {
"info": "GaslessTransactions",
"source": "https://github.com/tangem-developments/tangem-gasless-service",
"name": "gaslessTransaction"
},
"0xf9b181bf": {
"info": "GaslessTransactions",
"source": "https://github.com/tangem-developments/tangem-gasless-service",
"name": "gaslessTransaction"
} }
} }

View file

@ -0,0 +1,36 @@
package com.tangem.domain
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import java.io.File
/**
* Guards the `contract_methods.json` asset consumed by `SdkTransactionTypeConverter` (via
* `DefaultWalletManagersFacade.readSmartContractMethods`). History marking of gasless fee transfers
* relies on every gasless entry-point selector being mapped to the `gaslessTransaction` method name.
*/
internal class ContractMethodsAssetTest {
private val methods: Map<String, Map<String, String>> by lazy {
val json = File("src/main/assets/contract_methods.json").readText()
val type = Types.newParameterizedType(
Map::class.java,
String::class.java,
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java),
)
requireNotNull(Moshi.Builder().build().adapter<Map<String, Map<String, String>>>(type).fromJson(json))
}
@ParameterizedTest
@ValueSource(strings = ["0x6234d42b", "0x4b072692", "0xf9b181bf"])
fun `GIVEN gasless selector WHEN asset parsed THEN maps to gaslessTransaction`(selector: String) {
val entry = methods[selector]
assertThat(entry).isNotNull()
assertThat(entry?.get("name")).isEqualTo("gaslessTransaction")
}
}

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.domain.transaction" namespace = "com.tangem.domain.transaction"
} }
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies { dependencies {
implementation(deps.kotlin.coroutines) implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core) implementation(deps.arrow.core)
@ -42,6 +46,8 @@ dependencies {
implementation(projects.domain.notifications) implementation(projects.domain.notifications)
api(projects.domain.networks) api(projects.domain.networks)
testRuntimeOnly(deps.test.junit5.engine)
testRuntimeOnly(deps.test.junit5.vintage.engine)
testImplementation(projects.common.test) testImplementation(projects.common.test)
testImplementation(projects.test.core) testImplementation(projects.test.core)
testImplementation(projects.test.mock) testImplementation(projects.test.mock)

View file

@ -18,6 +18,7 @@ sealed class GetFeeError {
data object NetworkIsNotSupported : GaslessError() data object NetworkIsNotSupported : GaslessError()
data object NoSupportedTokensFound : GaslessError() data object NoSupportedTokensFound : GaslessError()
data object NotEnoughFunds : GaslessError() data object NotEnoughFunds : GaslessError()
data object ModuleUpdateUnavailable : GaslessError()
data class DataError(val cause: Throwable?) : GaslessError() data class DataError(val cause: Throwable?) : GaslessError()
} }

View file

@ -3,6 +3,7 @@ package com.tangem.domain.transaction
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.models.Eip7702Authorization 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.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData
import java.math.BigInteger import java.math.BigInteger
@ -57,6 +58,33 @@ interface GaslessTransactionRepository {
eip7702Auth: Eip7702Authorization? = null, eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult ): GaslessSignedTransactionResult
/**
* Sends a gasless BATCH transaction to the gasless service for signing and returns the signed result.
*
* Mirrors [signGaslessTransaction] but accepts multiple transactions executed in array order.
* Index 0 is the user's main transaction; subsequent entries are appended operations
* (e.g. a yield `withdraw` to cover the fee from staked balance).
*
* @param gaslessBatchTransactionData domain model containing:
* - transactions: ordered list of calls (to, value, data)
* - fee: token payment configuration
* - nonce: user's contract nonce to prevent replay attacks
* @param signature user's ECDSA signature of the batch transaction in hex format (0x...)
* @param userAddress user's Ethereum address (EOA or contract wallet)
* @param network blockchain network used to determine chainId for the request
* @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract
* @return [GaslessSignedTransactionResult] containing the fully signed transaction ready to broadcast
* @throws IllegalStateException if network is not supported or chainId cannot be determined
* @throws Exception if service returns error or network request fails
*/
suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult
/** /**
* Hardcoded value as baseGas * Hardcoded value as baseGas
*/ */

View file

@ -0,0 +1,33 @@
package com.tangem.domain.transaction
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
/**
* Narrow repository interface used by [com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase]
* to query yield-module state without introducing a circular module dependency.
*
* [com.tangem.domain.yield.supply.YieldSupplyTransactionRepository] extends this interface.
*/
interface GaslessYieldRepository {
/** Returns the effective (liquid) protocol balance for [cryptoCurrency], or null if unavailable. */
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
/** Returns the yield-module contract address for [cryptoCurrency], or null if unavailable. */
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
/**
* Builds an upgrade-wrapped `withdraw(yieldToken, amount)` call data for the user's yield module.
* @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
* @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
*/
suspend fun createPartialWithdrawCallData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
amount: Amount,
): SmartContractCallData
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.transaction.models
import java.math.BigInteger
/**
* Domain model for a gasless BATCH transaction (EIP-712 primaryType `GaslessBatchTransaction`).
* Reuses [GaslessTransactionData.Transaction] and [GaslessTransactionData.Fee].
*
* @property transactions ordered list index 0 is the user's main transaction, subsequent entries
* are appended operations (e.g. the yield `withdraw`). Executed in array order.
* @property fee fee payment configuration.
* @property nonce nonce from the user's contract.
*/
data class GaslessBatchTransactionData(
val transactions: List<GaslessTransactionData.Transaction>,
val fee: GaslessTransactionData.Fee,
val nonce: BigInteger,
)

View file

@ -0,0 +1,39 @@
package com.tangem.domain.transaction.models
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigInteger
/**
* Resolved strategy for paying a gasless transaction fee. Produced by ResolveGaslessFeePlanUseCase,
* consumed by CreateAndSendGaslessTransactionUseCase.
*/
sealed interface GaslessFeePlan {
/** Pay in the native coin (enough native balance) — falls back to the standard fee. */
data class NativePay(val fee: Fee) : GaslessFeePlan
/** Pay the fee from the token's plain balance. */
data class TokenPay(
val feeToken: CryptoCurrency.Token,
val fee: Fee.Ethereum.TokenCurrency,
) : GaslessFeePlan
/**
* Pay the fee by first withdrawing the token from the user's yield module (appended as a second
* batch transaction). [withdrawCallData] is already upgrade-wrapped when the module needs an upgrade.
*
* Note: the executed on-chain withdraw amount is the (floor-rounded) value encoded inside
* [withdrawCallData]. [withdrawAmount] is a CEILING-rounded copy intended for DISPLAY (e.g. a future
* "X withdrawn from Yield" notification); it intentionally may exceed the executed amount by 1 base
* unit. Do NOT use [withdrawAmount] to build the on-chain call data.
*/
data class TokenPayWithYieldWithdraw(
val feeToken: CryptoCurrency.Token,
val fee: Fee.Ethereum.TokenCurrency,
val withdrawAmount: BigInteger,
val withdrawCallData: SmartContractCallData,
val yieldModuleAddress: String,
) : GaslessFeePlan
}

View file

@ -15,16 +15,11 @@ data class GaslessTransactionData(
val nonce: BigInteger, 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( data class Transaction(
val to: String, val to: String,
val value: BigInteger, val value: BigInteger,
val gasLimit: BigInteger,
val data: ByteArray, val data: ByteArray,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -35,6 +30,7 @@ data class GaslessTransactionData(
if (to != other.to) return false if (to != other.to) return false
if (value != other.value) return false if (value != other.value) return false
if (gasLimit != other.gasLimit) return false
if (!data.contentEquals(other.data)) return false if (!data.contentEquals(other.data)) return false
return true return true
@ -43,6 +39,7 @@ data class GaslessTransactionData(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = to.hashCode() var result = to.hashCode()
result = 31 * result + value.hashCode() result = 31 * result + value.hashCode()
result = 31 * result + gasLimit.hashCode()
result = 31 * result + data.contentHashCode() result = 31 * result + data.contentHashCode()
return result return result
} }

View file

@ -2,8 +2,28 @@ package com.tangem.domain.transaction.models
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigInteger
data class TransactionFeeExtended( data class TransactionFeeExtended(
val transactionFee: TransactionFee, val transactionFee: TransactionFee,
val feeTokenId: CryptoCurrency.ID, val feeTokenId: CryptoCurrency.ID,
/**
* Resolved gasless fee strategy. Non-null only for token-paid gasless fees; null for native fee.
* A null value is semantically equivalent to [GaslessFeePlan.NativePay] consumers MUST treat them
* the same. [GaslessFeePlan.NativePay] is produced only by ResolveGaslessFeePlanUseCase.
* When it is [GaslessFeePlan.TokenPayWithYieldWithdraw], the send step builds a batch transaction.
*/
val gaslessFeePlan: GaslessFeePlan? = null,
/**
* Per-call gas limit for the user's main transaction, bound into the v2 EIP-712 hash
* ([GaslessTransactionData.Transaction.gasLimit]). Non-null only on the token-fee (gasless) path,
* where it equals the estimated execution gas of the user's transaction.
*/
val mainTransactionGasLimit: BigInteger? = null,
/**
* Per-call gas limit for the appended yield-withdraw sub-call in a batch. Non-null only when the
* fee is paid via [GaslessFeePlan.TokenPayWithYieldWithdraw]; used as the withdraw transaction's
* [GaslessTransactionData.Transaction.gasLimit].
*/
val withdrawGasLimit: BigInteger? = null,
) )

View file

@ -27,6 +27,8 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.Eip7702Authorization import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessFeePlan
import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
@ -38,6 +40,7 @@ class CreateAndSendGaslessTransactionUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository, private val gaslessTransactionRepository: GaslessTransactionRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository, private val cardSdkConfigRepository: CardSdkConfigRepository,
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner, private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
private val isGaslessV2Enabled: Boolean,
) { ) {
suspend operator fun invoke( suspend operator fun invoke(
@ -69,6 +72,12 @@ class CreateAndSendGaslessTransactionUseCase(
/** /**
* Prepares all necessary context for gasless transaction. * Prepares all necessary context for gasless transaction.
* Includes: wallet manager, gasless provider, token status, nonce, transaction data. * Includes: wallet manager, gasless provider, token status, nonce, transaction data.
*
* When the resolved fee plan is [GaslessFeePlan.TokenPayWithYieldWithdraw], the payload is a
* [GaslessPayload.Batch] with the user's main tx at index 0 and the yield-withdraw tx at index 1.
* [GaslessFeePlan.TokenPay] and a null plan produce a [GaslessPayload.Single] with the same
* single-transaction behavior as before. [GaslessFeePlan.NativePay] must never reach this use
* case it is guarded in [assembleGaslessPayload].
*/ */
private suspend fun prepareGaslessContext( private suspend fun prepareGaslessContext(
userWallet: UserWallet, userWallet: UserWallet,
@ -91,11 +100,17 @@ class CreateAndSendGaslessTransactionUseCase(
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress) val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
val gaslessTransactionData = createGaslessTransactionData( val mainTxGasLimit = fee.mainTransactionGasLimit
transactionData = transactionData, ?: error("Main transaction gas limit is required for a gasless (token-fee) transaction")
txFee = fee, val mainTx = buildTransaction(transactionData, mainTxGasLimit)
currency = currency, val feeObj = buildFee(fee, currency)
val payload = assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = gaslessContractNonce, nonce = gaslessContractNonce,
plan = fee.gaslessFeePlan,
withdrawGasLimit = fee.withdrawGasLimit,
) )
val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network) val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network)
@ -104,7 +119,7 @@ class CreateAndSendGaslessTransactionUseCase(
walletManager = walletManager, walletManager = walletManager,
gaslessDataProvider = gaslessDataProvider, gaslessDataProvider = gaslessDataProvider,
currency = currency, currency = currency,
gaslessTransactionData = gaslessTransactionData, payload = payload,
chainId = chainId, chainId = chainId,
) )
} }
@ -125,17 +140,30 @@ class CreateAndSendGaslessTransactionUseCase(
/** /**
* Signs gasless transaction and EIP-7702 authorization. * Signs gasless transaction and EIP-7702 authorization.
* Returns prepared signatures and authorization data. * Returns prepared signatures and authorization data.
*
* EIP-712 typed data is constructed from the payload:
* - [GaslessPayload.Single] [Eip712TypedDataBuilder.build] (single-transaction schema)
* - [GaslessPayload.Batch] [Eip712TypedDataBuilder.buildBatch] (batch schema)
*/ */
private suspend fun signGaslessTransactionByUser( private suspend fun signGaslessTransactionByUser(
userWallet: UserWallet, userWallet: UserWallet,
context: GaslessContext, context: GaslessContext,
transactionData: TransactionData.Uncompiled, transactionData: TransactionData.Uncompiled,
): SignedGaslessData { ): SignedGaslessData {
val eip712Data = Eip712TypedDataBuilder.build( val eip712Data = when (val payload = context.payload) {
gaslessTransaction = context.gaslessTransactionData, is GaslessPayload.Single -> Eip712TypedDataBuilder.build(
chainId = context.chainId, gaslessTransaction = payload.data,
verifyingContract = transactionData.sourceAddress, chainId = context.chainId,
) verifyingContract = transactionData.sourceAddress,
includeGasLimit = isGaslessV2Enabled,
)
is GaslessPayload.Batch -> Eip712TypedDataBuilder.buildBatch(
gaslessBatch = payload.data,
chainId = context.chainId,
verifyingContract = transactionData.sourceAddress,
includeGasLimit = isGaslessV2Enabled,
)
}
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data) val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider) val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider)
@ -182,19 +210,34 @@ class CreateAndSendGaslessTransactionUseCase(
/** /**
* Sends gasless transaction to the service. * Sends gasless transaction to the service.
*
* Routes to the appropriate repository call based on payload type:
* - [GaslessPayload.Single] [GaslessTransactionRepository.signGaslessTransaction]
* - [GaslessPayload.Batch] [GaslessTransactionRepository.signGaslessBatchTransaction]
*
* Pending-transaction tracking is always keyed on the main (user's) transaction only.
*/ */
private suspend fun signAndSendTransactionOnBackend( private suspend fun signAndSendTransactionOnBackend(
context: GaslessContext, context: GaslessContext,
signedData: SignedGaslessData, signedData: SignedGaslessData,
transactionData: TransactionData.Uncompiled, transactionData: TransactionData.Uncompiled,
): String { ): String {
val txHash = gaslessTransactionRepository.signGaslessTransaction( val txHash = when (val payload = context.payload) {
network = context.currency.network, is GaslessPayload.Single -> gaslessTransactionRepository.signGaslessTransaction(
gaslessTransactionData = context.gaslessTransactionData, network = context.currency.network,
signature = signedData.eip712Signature, gaslessTransactionData = payload.data,
userAddress = transactionData.sourceAddress, signature = signedData.eip712Signature,
eip7702Auth = signedData.eip7702Auth, userAddress = transactionData.sourceAddress,
).txHash eip7702Auth = signedData.eip7702Auth,
).txHash
is GaslessPayload.Batch -> gaslessTransactionRepository.signGaslessBatchTransaction(
network = context.currency.network,
gaslessBatchTransactionData = payload.data,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
}
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction( (context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
transactionData = transactionData, transactionData = transactionData,
@ -241,23 +284,10 @@ class CreateAndSendGaslessTransactionUseCase(
} }
} }
private suspend fun createGaslessTransactionData( private fun buildTransaction(
transactionData: TransactionData.Uncompiled, transactionData: TransactionData.Uncompiled,
txFee: TransactionFeeExtended, gasLimit: BigInteger,
currency: CryptoCurrency, ): GaslessTransactionData.Transaction {
nonce: BigInteger,
): GaslessTransactionData {
val transaction = buildTransaction(transactionData)
val fee = buildFee(txFee, currency)
return GaslessTransactionData(
transaction = transaction,
fee = fee,
nonce = nonce,
)
}
private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction {
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
?: error("Ethereum call data is required") ?: error("Ethereum call data is required")
@ -268,6 +298,7 @@ class CreateAndSendGaslessTransactionUseCase(
return GaslessTransactionData.Transaction( return GaslessTransactionData.Transaction(
to = getDestinationAddress(transactionData), to = getDestinationAddress(transactionData),
value = nativeAmount, value = nativeAmount,
gasLimit = gasLimit,
data = callData.data, data = callData.data,
) )
} }
@ -295,20 +326,28 @@ class CreateAndSendGaslessTransactionUseCase(
private suspend fun getEIP7702DataForGasless( private suspend fun getEIP7702DataForGasless(
gaslessDataProvider: EthereumGaslessDataProvider, gaslessDataProvider: EthereumGaslessDataProvider,
): EIP7702AuthorizationData { ): EIP7702AuthorizationData {
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = false)) { return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = isGaslessV2Enabled)) {
is Result.Failure -> throw dataResult.error is Result.Failure -> throw dataResult.error
is Result.Success -> dataResult.data is Result.Success -> dataResult.data
} }
} }
private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { /**
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData * Discriminated union of the gasless transaction payload to sign and send.
val contractAddress = txData.contractAddress *
return if (ethereumCallData is EthereumYieldSupplySendCallData) { * [Single] carries a single-transaction payload (the pre-existing path).
ethereumCallData.destinationAddress * [Batch] carries a batch payload where the yield-withdraw call is appended as the second
} else { * transaction so that staked tokens are unlocked before the fee is settled.
contractAddress ?: error("supports only Token transaction with contract address") */
} internal sealed interface GaslessPayload {
/** Single-transaction path — behavior is identical to the original implementation. */
data class Single(val data: GaslessTransactionData) : GaslessPayload
/**
* Batch path used when [GaslessFeePlan.TokenPayWithYieldWithdraw] is resolved.
* [data.transactions] has the user's main tx at index 0 and the withdraw tx at index 1.
*/
data class Batch(val data: GaslessBatchTransactionData) : GaslessPayload
} }
/** /**
@ -318,7 +357,7 @@ class CreateAndSendGaslessTransactionUseCase(
val walletManager: WalletManager, val walletManager: WalletManager,
val gaslessDataProvider: EthereumGaslessDataProvider, val gaslessDataProvider: EthereumGaslessDataProvider,
val currency: CryptoCurrency, val currency: CryptoCurrency,
val gaslessTransactionData: GaslessTransactionData, val payload: GaslessPayload,
val chainId: Int, val chainId: Int,
) )
@ -353,9 +392,75 @@ class CreateAndSendGaslessTransactionUseCase(
} }
} }
private companion object { internal companion object {
/**
* Assembles the [GaslessPayload] from already-built domain objects and the resolved fee plan.
*
* Dispatch rules:
* - [GaslessFeePlan.TokenPayWithYieldWithdraw] [GaslessPayload.Batch]: the yield-withdraw
* call is appended as the second transaction so that the fee token balance is topped up
* before the gasless service processes the fee.
* - [GaslessFeePlan.TokenPay] or `null` [GaslessPayload.Single]: single-transaction path,
* identical to the original implementation. `null` is a legitimate value meaning the plan
* was not explicitly resolved.
* - [GaslessFeePlan.NativePay] error: native-pay fees must never reach this use case
* (they are handled by the standard send path).
*/
internal fun assembleGaslessPayload(
mainTx: GaslessTransactionData.Transaction,
feeObj: GaslessTransactionData.Fee,
nonce: BigInteger,
plan: GaslessFeePlan?,
withdrawGasLimit: BigInteger?,
): GaslessPayload = when (plan) {
is GaslessFeePlan.TokenPayWithYieldWithdraw -> GaslessPayload.Batch(
GaslessBatchTransactionData(
transactions = listOf(
mainTx,
GaslessTransactionData.Transaction(
to = plan.yieldModuleAddress,
value = BigInteger.ZERO,
gasLimit = withdrawGasLimit
?: error("Withdraw gas limit is required for a yield-withdraw batch"),
data = plan.withdrawCallData.data,
),
),
fee = feeObj,
nonce = nonce,
),
)
is GaslessFeePlan.TokenPay, null -> GaslessPayload.Single(
GaslessTransactionData(transaction = mainTx, fee = feeObj, nonce = nonce),
)
is GaslessFeePlan.NativePay -> error("NativePay must not reach the gasless send path")
}
fun BigInteger.toFormattedHex(bytes: Int): String { fun BigInteger.toFormattedHex(bytes: Int): String {
return toByteArray().normalizeByteArray(bytes).toHexString().formatHex() return toByteArray().normalizeByteArray(bytes).toHexString().formatHex()
} }
/**
* Resolves the on-chain `to` for the user's main gasless sub-call.
*
* - Yield-supply send (`EthereumYieldSupplySendCallData`, selector 0x0779afe6): `send(token, dest,
* amount)` is a method ON the user's yield module the executor must CALL the module (it holds the
* staked funds and routes the transfer); the recipient is already encoded inside the call data.
* [TransactionData.Uncompiled.destinationAddress] is patched to the module address in
* `DefaultTransactionRepository.createTransaction`, mirroring the non-gasless send path (and the
* withdraw sub-call's `to`). Reading `ethereumCallData.destinationAddress` (the recipient) instead
* makes the executor call a plain address with the module's calldata, reverting the whole batch with
* GAS_ESTIMATION_FAILED / require(false).
* - Otherwise (e.g. ERC-20 transfer): `to` is the contract the calldata runs against
* ([TransactionData.Uncompiled.contractAddress], the token contract).
*/
internal fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
txData.destinationAddress
} else {
txData.contractAddress ?: error("supports only Token transaction with contract address")
}
}
} }
} }

View file

@ -1,6 +1,7 @@
package com.tangem.domain.transaction.usecase.gasless package com.tangem.domain.transaction.usecase.gasless
import com.tangem.common.extensions.toHexString import com.tangem.common.extensions.toHexString
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -26,6 +27,7 @@ object Eip712TypedDataBuilder {
private const val DOMAIN_NAME = "Tangem7702GaslessExecutor" private const val DOMAIN_NAME = "Tangem7702GaslessExecutor"
private const val DOMAIN_VERSION = "1" private const val DOMAIN_VERSION = "1"
private const val PRIMARY_TYPE = "GaslessTransaction" private const val PRIMARY_TYPE = "GaslessTransaction"
private const val PRIMARY_TYPE_BATCH = "GaslessBatchTransaction"
/** /**
* Builds EIP-712 typed data JSON for gasless transaction. * Builds EIP-712 typed data JSON for gasless transaction.
@ -35,47 +37,106 @@ object Eip712TypedDataBuilder {
* @param verifyingContract address of the deployed gasless executor contract * @param verifyingContract address of the deployed gasless executor contract
* @return JSON string ready for EIP-712 signing * @return JSON string ready for EIP-712 signing
*/ */
fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String { fun build(
gaslessTransaction: GaslessTransactionData,
chainId: Int,
verifyingContract: String,
includeGasLimit: Boolean = true,
): String {
val typedData = JSONObject().apply { val typedData = JSONObject().apply {
put("types", buildTypes()) put("types", buildTypes(includeGasLimit))
put("primaryType", PRIMARY_TYPE) put("primaryType", PRIMARY_TYPE)
put("domain", buildDomain(chainId, verifyingContract)) put("domain", buildDomain(chainId, verifyingContract))
put("message", buildMessage(gaslessTransaction)) put("message", buildMessage(gaslessTransaction, includeGasLimit))
} }
return typedData.toString() return typedData.toString()
} }
/**
* Builds EIP-712 typed data JSON for gasless batch transaction.
*
* @param gaslessBatch domain model with ordered list of transactions and fee data
* @param chainId blockchain network chain ID
* @param verifyingContract address of the deployed gasless executor contract
* @return JSON string ready for EIP-712 signing
*/
fun buildBatch(
gaslessBatch: GaslessBatchTransactionData,
chainId: Int,
verifyingContract: String,
includeGasLimit: Boolean = true,
): String {
require(
gaslessBatch.transactions.isNotEmpty(),
) { "GaslessBatchTransaction must contain at least one transaction" }
val typedData = JSONObject().apply {
put("types", buildBatchTypes(includeGasLimit))
put("primaryType", PRIMARY_TYPE_BATCH)
put("domain", buildDomain(chainId, verifyingContract))
put("message", buildBatchMessage(gaslessBatch, includeGasLimit))
}
return typedData.toString()
}
/**
* Builds the type definitions for all structures in the batch variant.
* Uses `Transaction[]` for the ordered transactions array.
*/
private fun buildBatchTypes(includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("EIP712Domain", buildEip712DomainTypeProperties())
put("Transaction", buildTransactionTypeProperties(includeGasLimit))
put("Fee", buildFeeTypeProperties())
put("GaslessBatchTransaction", buildGaslessBatchTransactionTypeProperties())
}
}
private fun buildGaslessBatchTransactionTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("transactions", "Transaction[]"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
}
}
/**
* Builds the message data from gasless batch transaction.
*/
private fun buildBatchMessage(gaslessBatch: GaslessBatchTransactionData, includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("transactions", buildTransactionsArray(gaslessBatch.transactions, includeGasLimit))
put("fee", buildFeeMessage(gaslessBatch.fee))
put("nonce", gaslessBatch.nonce.toString())
}
}
private fun buildTransactionsArray(
transactions: List<GaslessTransactionData.Transaction>,
includeGasLimit: Boolean,
): JSONArray {
return JSONArray().apply {
transactions.forEach { tx -> put(buildTransactionMessage(tx, includeGasLimit)) }
}
}
/** /**
* Builds the type definitions for all structures. * Builds the type definitions for all structures.
* This schema is fixed and defines the structure of the data being signed. * This schema is fixed and defines the structure of the data being signed.
*/ */
@Suppress("NestedScopeFunctions") private fun buildTypes(includeGasLimit: Boolean): JSONObject {
private fun buildTypes(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
put("EIP712Domain", JSONArray().apply { put("EIP712Domain", buildEip712DomainTypeProperties())
put(typeProperty("name", "string")) put("Transaction", buildTransactionTypeProperties(includeGasLimit))
put(typeProperty("version", "string")) put("Fee", buildFeeTypeProperties())
put(typeProperty("chainId", "uint256")) put("GaslessTransaction", buildGaslessTransactionTypeProperties())
put(typeProperty("verifyingContract", "address")) }
}) }
put("Transaction", JSONArray().apply {
put(typeProperty("to", "address")) private fun buildGaslessTransactionTypeProperties(): JSONArray {
put(typeProperty("value", "uint256")) return JSONArray().apply {
put(typeProperty("data", "bytes")) put(typeProperty("transaction", "Transaction"))
}) put(typeProperty("fee", "Fee"))
put("Fee", JSONArray().apply { put(typeProperty("nonce", "uint256"))
put(typeProperty("feeToken", "address"))
put(typeProperty("maxTokenFee", "uint256"))
put(typeProperty("coinPriceInToken", "uint256"))
put(typeProperty("feeTransferGasLimit", "uint256"))
put(typeProperty("baseGas", "uint256"))
put(typeProperty("feeReceiver", "address"))
})
put("GaslessTransaction", JSONArray().apply {
put(typeProperty("transaction", "Transaction"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
})
} }
} }
@ -104,23 +165,71 @@ object Eip712TypedDataBuilder {
/** /**
* Builds the message data from gasless transaction. * Builds the message data from gasless transaction.
*/ */
@Suppress("NestedScopeFunctions") private fun buildMessage(gaslessTransaction: GaslessTransactionData, includeGasLimit: Boolean): JSONObject {
private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject {
return JSONObject().apply { return JSONObject().apply {
put("transaction", JSONObject().apply { put("transaction", buildTransactionMessage(gaslessTransaction.transaction, includeGasLimit))
put("to", gaslessTransaction.transaction.to) put("fee", buildFeeMessage(gaslessTransaction.fee))
put("value", gaslessTransaction.transaction.value.toString())
put("data", gaslessTransaction.transaction.data.toHexString())
})
put("fee", JSONObject().apply {
put("feeToken", gaslessTransaction.fee.feeToken)
put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString())
put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString())
put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString())
put("baseGas", gaslessTransaction.fee.baseGas.toString())
put("feeReceiver", gaslessTransaction.fee.feeReceiver)
})
put("nonce", gaslessTransaction.nonce.toString()) put("nonce", gaslessTransaction.nonce.toString())
} }
} }
private fun buildTransactionMessage(
transaction: GaslessTransactionData.Transaction,
includeGasLimit: Boolean,
): JSONObject {
return JSONObject().apply {
put("to", transaction.to)
put("value", transaction.value.toString())
if (includeGasLimit) put("gasLimit", transaction.gasLimit.toString())
put("data", transaction.data.toHexString())
}
}
// region Shared type schema helpers
private fun buildEip712DomainTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("name", "string"))
put(typeProperty("version", "string"))
put(typeProperty("chainId", "uint256"))
put(typeProperty("verifyingContract", "address"))
}
}
private fun buildTransactionTypeProperties(includeGasLimit: Boolean): JSONArray {
return JSONArray().apply {
put(typeProperty("to", "address"))
put(typeProperty("value", "uint256"))
if (includeGasLimit) put(typeProperty("gasLimit", "uint256"))
put(typeProperty("data", "bytes"))
}
}
private fun buildFeeTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("feeToken", "address"))
put(typeProperty("maxTokenFee", "uint256"))
put(typeProperty("coinPriceInToken", "uint256"))
put(typeProperty("feeTransferGasLimit", "uint256"))
put(typeProperty("baseGas", "uint256"))
put(typeProperty("feeReceiver", "address"))
}
}
// endregion
// region Shared message helpers
private fun buildFeeMessage(fee: GaslessTransactionData.Fee): JSONObject {
return JSONObject().apply {
put("feeToken", fee.feeToken)
put("maxTokenFee", fee.maxTokenFee.toString())
put("coinPriceInToken", fee.coinPriceInToken.toString())
put("feeTransferGasLimit", fee.feeTransferGasLimit.toString())
put("baseGas", fee.baseGas.toString())
put("feeReceiver", fee.feeReceiver)
}
}
// endregion
} }

View file

@ -18,12 +18,14 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal import java.math.BigDecimal
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -31,6 +33,7 @@ class EstimateFeeForGaslessTxUseCase(
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig, private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository, private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val estimateFeeUseCase: EstimateFeeUseCase, private val estimateFeeUseCase: EstimateFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository, private val currencyChecksRepository: CurrencyChecksRepository,
@ -40,6 +43,7 @@ class EstimateFeeForGaslessTxUseCase(
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig, demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
) )
suspend operator fun invoke( suspend operator fun invoke(
@ -153,11 +157,11 @@ class EstimateFeeForGaslessTxUseCase(
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network, network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency -> ).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress (currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
}.toSet() }.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } .filterNot { it.value.amount?.isZero() == true || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount } .sortedByDescending { it.value.amount }
.filter { status -> .filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false val token = status.currency as? CryptoCurrency.Token ?: return@filter false

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -22,18 +23,22 @@ import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal import java.math.BigDecimal
@Suppress("LongParameterList")
class EstimateFeeForTokenUseCase( class EstimateFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository, private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig, private val demoConfig: DemoConfig,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val currencyChecksRepository: CurrencyChecksRepository, private val currencyChecksRepository: CurrencyChecksRepository,
private val isYieldWithdrawEnabled: Boolean,
) { ) {
private val tokenFeeCalculator = TokenFeeCalculator( private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig, demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
) )
suspend operator fun invoke( suspend operator fun invoke(
@ -70,11 +75,15 @@ class EstimateFeeForTokenUseCase(
val walletManager = prepareWalletManager(userWallet, token.network) val walletManager = prepareWalletManager(userWallet, token.network)
val isYieldActive = isYieldWithdrawEnabled &&
feeTokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
tokenFeeCalculator.calculateTokenFee( tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager, walletManager = walletManager,
tokenForPayFeeStatus = feeTokenCurrencyStatus, tokenForPayFeeStatus = feeTokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth, initialFee = initialFeeEth,
isYieldActive = isYieldActive,
).bind() ).bind()
}, },
catch = { catch = {

View file

@ -19,6 +19,7 @@ class GetAvailableFeeTokensUseCase(
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val gaslessTransactionRepository: GaslessTransactionRepository, private val gaslessTransactionRepository: GaslessTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository, private val currencyChecksRepository: CurrencyChecksRepository,
private val isYieldWithdrawEnabled: Boolean,
) { ) {
/** /**
@ -69,7 +70,7 @@ class GetAvailableFeeTokensUseCase(
}.toSet() }.toSet()
return userCurrenciesStatuses return userCurrenciesStatuses
.asSequence() .asSequence()
.filter { it.value.yieldSupplyStatus == null } .filter { isEligibleFeeToken(it, isYieldWithdrawEnabled) }
.filter { it.currency.network.id == network.id } .filter { it.currency.network.id == network.id }
.filter { currencyStatus -> .filter { currencyStatus ->
val token = currencyStatus.currency val token = currencyStatus.currency
@ -77,4 +78,12 @@ class GetAvailableFeeTokensUseCase(
} }
.toList() .toList()
} }
internal companion object {
internal fun isEligibleFeeToken(status: CryptoCurrencyStatus, isYieldWithdrawEnabled: Boolean): Boolean {
val yieldSupplyStatus = status.value.yieldSupplyStatus ?: return true
return isYieldWithdrawEnabled && yieldSupplyStatus.isActive
}
}
} }

View file

@ -6,6 +6,7 @@ import arrow.core.raise.Raise
import arrow.core.raise.catch import arrow.core.raise.catch
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
@ -19,6 +20,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -32,15 +34,19 @@ class GetFeeForGaslessUseCase(
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig, private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository, private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getFeeUseCase: GetFeeUseCase, private val getFeeUseCase: GetFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository, private val currencyChecksRepository: CurrencyChecksRepository,
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
private val isYieldWithdrawEnabled: Boolean,
) { ) {
private val tokenFeeCalculator = TokenFeeCalculator( private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig, demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
) )
suspend operator fun invoke( suspend operator fun invoke(
@ -80,11 +86,13 @@ class GetFeeForGaslessUseCase(
).bind() ).bind()
selectFeePaymentStrategy( selectFeePaymentStrategy(
userWallet = userWallet,
accountStatusList = accountStatusList, accountStatusList = accountStatusList,
walletManager = walletManager, walletManager = walletManager,
nativeCurrencyStatus = nativeCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus,
network = network, network = network,
initialFee = initialFee, initialFee = initialFee,
transactionData = transactionData,
) )
}, },
catch = { catch = {
@ -108,12 +116,15 @@ class GetFeeForGaslessUseCase(
return ethereumWalletManager return ethereumWalletManager
} }
@Suppress("LongParameterList")
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy( private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
userWallet: UserWallet,
accountStatusList: AccountStatusList, accountStatusList: AccountStatusList,
walletManager: EthereumWalletManager, walletManager: EthereumWalletManager,
nativeCurrencyStatus: CryptoCurrencyStatus, nativeCurrencyStatus: CryptoCurrencyStatus,
network: Network, network: Network,
initialFee: TransactionFee, initialFee: TransactionFee,
transactionData: TransactionData,
): TransactionFeeExtended { ): TransactionFeeExtended {
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
@ -128,10 +139,12 @@ class GetFeeForGaslessUseCase(
nativeCoinSelectedResult nativeCoinSelectedResult
} else { } else {
findTokensToPayFee( findTokensToPayFee(
userWallet = userWallet,
walletManager = walletManager, walletManager = walletManager,
initialTxFee = initialFee, initialTxFee = initialFee,
nativeCurrencyStatus = nativeCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus,
networkCurrenciesStatuses = networkCurrenciesStatuses, networkCurrenciesStatuses = networkCurrenciesStatuses,
transactionData = transactionData,
).getOrElse { error -> ).getOrElse { error ->
when (error) { when (error) {
GaslessError.NotEnoughFunds -> nativeCoinSelectedResult GaslessError.NotEnoughFunds -> nativeCoinSelectedResult
@ -141,12 +154,14 @@ class GetFeeForGaslessUseCase(
} }
} }
@Suppress("NullableToStringCall") @Suppress("NullableToStringCall", "LongParameterList")
private suspend fun findTokensToPayFee( private suspend fun findTokensToPayFee(
userWallet: UserWallet,
walletManager: EthereumWalletManager, walletManager: EthereumWalletManager,
initialTxFee: TransactionFee, initialTxFee: TransactionFee,
nativeCurrencyStatus: CryptoCurrencyStatus, nativeCurrencyStatus: CryptoCurrencyStatus,
networkCurrenciesStatuses: List<CryptoCurrencyStatus>, networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
transactionData: TransactionData,
): Either<GetFeeError, TransactionFeeExtended> = either { ): Either<GetFeeError, TransactionFeeExtended> = either {
val initialFee = initialTxFee.normal as? Fee.Ethereum val initialFee = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError( ?: raiseIllegalStateError(
@ -156,29 +171,109 @@ class GetFeeForGaslessUseCase(
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network, network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency -> ).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress (currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
}.toSet() }.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount }
.filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
token.contractAddress.lowercase() in supportedGaslessTokens
}
/** /**
* Selects token with highest balance to maximize chances of successful fee payment. * Yield-aware candidate selection:
* Returns null if no suitable tokens found. * a token is eligible if it is a supported gasless token AND
* (total balance > 0 OR has an active yield position).
* Sorted by total balance descending to maximise chances of covering the fee. For a yield token
* value.amount is already effectiveBalance (liquid EOA + effectiveProtocolBalance), so it must NOT
* be summed with effectiveProtocolBalance again that would double-count the module portion.
*/ */
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull() val candidates = networkCurrenciesStatuses
?: raise(GaslessError.NoSupportedTokensFound) .asSequence()
.filter { it.currency is CryptoCurrency.Token }
.filter { (it.currency as CryptoCurrency.Token).contractAddress.lowercase() in supportedGaslessTokens }
.filter { status ->
val total = status.value.amount ?: BigDecimal.ZERO
total > BigDecimal.ZERO || isYieldWithdrawEnabled && status.value.yieldSupplyStatus?.isActive == true
}
.sortedByDescending { status -> status.value.amount ?: BigDecimal.ZERO }
return tokenFeeCalculator.calculateTokenFee( val tokenForPayFeeStatus = candidates.firstOrNull() ?: raise(GaslessError.NoSupportedTokensFound)
val isYieldActive = isYieldWithdrawEnabled && tokenForPayFeeStatus.value.yieldSupplyStatus?.isActive == true
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager, walletManager = walletManager,
tokenForPayFeeStatus = tokenForPayFeeStatus, tokenForPayFeeStatus = tokenForPayFeeStatus,
nativeCurrencyStatus = nativeCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFee, initialFee = initialFee,
isYieldActive = isYieldActive,
userWallet = userWallet,
).bind()
attachGaslessFeePlan(
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
userWallet = userWallet,
tokenStatus = tokenForPayFeeStatus,
tokenFeeExtended = tokenFeeExtended,
transactionData = transactionData,
isYieldActive = isYieldActive,
) )
} }
}
/**
* Resolves the [com.tangem.domain.transaction.models.GaslessFeePlan] for [tokenStatus] paying the gasless
* fee and attaches it to [tokenFeeExtended]. Shared by the auto path ([GetFeeForGaslessUseCase]) and the
* manual fee-token selection path ([GetFeeForTokenUseCase]) so both produce identical plans.
*/
@Suppress("LongParameterList")
internal suspend fun Raise<GetFeeError>.attachGaslessFeePlan(
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
userWallet: UserWallet,
tokenStatus: CryptoCurrencyStatus,
tokenFeeExtended: TransactionFeeExtended,
transactionData: TransactionData,
isYieldActive: Boolean,
): TransactionFeeExtended {
val feeInTokenCurrency = tokenFeeExtended.transactionFee.normal as? Fee.Ethereum.TokenCurrency
?: raiseIllegalStateError("gasless token fee must be Fee.Ethereum.TokenCurrency")
val feeTokenContract = (tokenStatus.currency as? CryptoCurrency.Token)?.contractAddress
?: raiseIllegalStateError("gasless fee currency must be a token")
val plan = resolveGaslessFeePlanUseCase(
userWallet = userWallet,
tokenStatus = tokenStatus,
tokenFee = feeInTokenCurrency,
isYieldActive = isYieldActive,
sendAmountInFeeToken = computeSendAmountInFeeToken(transactionData, feeTokenContract),
).bind()
return tokenFeeExtended.copy(gaslessFeePlan = plan)
}
/**
* Computes how much of the fee token is also being spent in the main transaction body.
*
* Gasless token-fee transactions MUST supply uncompiled data (the resolver needs the raw amount to
* account for it in the required-balance check). A compiled tx or a null sent amount on the
* matching-token path are both programmer errors, so they raise loudly instead of silently
* under-accounting as ZERO.
*
* @param transactionData the raw transaction data passed into [GetFeeForGaslessUseCase].
* @param feeTokenContract the contract address of the token selected to pay the gasless fee.
* @return the sent amount when [feeTokenContract] matches the sent-token contract,
* or [BigDecimal.ZERO] when a different token is being sent.
*/
internal fun Raise<GetFeeError>.computeSendAmountInFeeToken(
transactionData: TransactionData,
feeTokenContract: String,
): BigDecimal {
// Gasless token-fee requires uncompiled tx data (mirrors CreateAndSendGaslessTransactionUseCase).
val uncompiled = transactionData as? TransactionData.Uncompiled
?: raiseIllegalStateError("gasless token fee requires uncompiled transaction data")
val sentTokenContract = when (val type = uncompiled.amount.type) {
is AmountType.Token -> type.token.contractAddress
is AmountType.TokenYieldSupply -> type.token.contractAddress
else -> null
}
return if (sentTokenContract != null && sentTokenContract.equals(feeTokenContract, ignoreCase = true)) {
uncompiled.amount.value
?: raiseIllegalStateError("sent amount is null while paying the gasless fee in the sent token")
} else {
BigDecimal.ZERO
}
} }

View file

@ -17,24 +17,30 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
@Suppress("LongParameterList")
class GetFeeForTokenUseCase( class GetFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository, private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig, private val demoConfig: DemoConfig,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val currencyChecksRepository: CurrencyChecksRepository, private val currencyChecksRepository: CurrencyChecksRepository,
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
private val isYieldWithdrawEnabled: Boolean,
) { ) {
private val tokenFeeCalculator = TokenFeeCalculator( private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig, demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
) )
suspend operator fun invoke( suspend operator fun invoke(
@ -74,12 +80,30 @@ class GetFeeForTokenUseCase(
raiseIllegalStateError("Token currency not found for network ${token.network.id}") raiseIllegalStateError("Token currency not found for network ${token.network.id}")
} }
tokenFeeCalculator.calculateTokenFee( val isYieldActive = isYieldWithdrawEnabled &&
tokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager, walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus, tokenForPayFeeStatus = tokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth, initialFee = initialFeeEth,
isYieldActive = isYieldActive,
userWallet = userWallet,
).bind() ).bind()
if (isYieldActive) {
attachGaslessFeePlan(
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
userWallet = userWallet,
tokenStatus = tokenCurrencyStatus,
tokenFeeExtended = tokenFeeExtended,
transactionData = transactionData,
isYieldActive = true,
)
} else {
tokenFeeExtended
}
}, },
catch = { catch = {
raise(GaslessError.DataError(it)) raise(GaslessError.DataError(it))

View file

@ -0,0 +1,97 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.GaslessFeePlan
import java.math.BigDecimal
import java.math.RoundingMode
class ResolveGaslessFeePlanUseCase(
private val gaslessYieldRepository: GaslessYieldRepository,
) {
suspend operator fun invoke(
userWallet: UserWallet,
tokenStatus: CryptoCurrencyStatus,
tokenFee: Fee.Ethereum.TokenCurrency,
isYieldActive: Boolean,
sendAmountInFeeToken: BigDecimal,
): Either<GetFeeError, GaslessFeePlan> = either {
val token = tokenStatus.currency as? CryptoCurrency.Token
?: raise(GaslessError.DataError(IllegalStateException("fee currency must be a token")))
val feeAmount = tokenFee.amount.value
?: raise(GaslessError.DataError(IllegalStateException("token fee amount is null")))
val totalBalance = tokenStatus.value.amount ?: BigDecimal.ZERO
val required = feeAmount + sendAmountInFeeToken
if (!isYieldActive) {
return@either if (totalBalance >= required) {
GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
} else {
raise(GaslessError.NotEnoughFunds)
}
}
val moduleBalance = gaslessYieldRepository
.getEffectiveProtocolBalance(userWallet.walletId, token) ?: BigDecimal.ZERO
// Liquid balance already on the EOA = total - what is held inside the yield module.
val liquidBalance = (totalBalance - moduleBalance).coerceAtLeast(BigDecimal.ZERO)
if (liquidBalance >= required) {
return@either GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
}
if (totalBalance < required) raise(GaslessError.NotEnoughFunds)
val liquidLeftForFee = (liquidBalance - sendAmountInFeeToken).coerceAtLeast(BigDecimal.ZERO)
val withdrawAmountDecimal = (feeAmount - liquidLeftForFee).coerceAtLeast(BigDecimal.ZERO)
val withdrawCallData = catch(
block = {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = userWallet.walletId,
cryptoCurrency = token,
amount = Amount(
token = Token(token.symbol, token.contractAddress, token.decimals),
value = withdrawAmountDecimal,
),
)
},
catch = { error ->
when (error) {
is YieldModuleUpgradeUnavailableException,
is YieldModuleVersionIndeterminateException,
-> raise(GaslessError.ModuleUpdateUnavailable)
else -> raise(GaslessError.DataError(error))
}
},
)
val yieldModuleAddress = gaslessYieldRepository
.getYieldContractAddress(userWallet.walletId, token)
?: raise(GaslessError.DataError(IllegalStateException("yield module address is null")))
GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = token,
fee = tokenFee,
withdrawAmount = withdrawAmountDecimal
.movePointRight(token.decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger(),
withdrawCallData = withdrawCallData,
yieldModuleAddress = yieldModuleAddress,
)
}
}

View file

@ -6,12 +6,15 @@ import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
@ -19,6 +22,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -34,6 +38,7 @@ internal class TokenFeeCalculator(
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val gaslessTransactionRepository: GaslessTransactionRepository, private val gaslessTransactionRepository: GaslessTransactionRepository,
private val demoConfig: DemoConfig, private val demoConfig: DemoConfig,
private val gaslessYieldRepository: GaslessYieldRepository,
) { ) {
suspend fun calculateInitialFee( suspend fun calculateInitialFee(
@ -90,16 +95,19 @@ internal class TokenFeeCalculator(
} }
} }
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexity")
suspend fun calculateTokenFee( suspend fun calculateTokenFee(
walletManager: EthereumWalletManager, walletManager: EthereumWalletManager,
tokenForPayFeeStatus: CryptoCurrencyStatus, tokenForPayFeeStatus: CryptoCurrencyStatus,
nativeCurrencyStatus: CryptoCurrencyStatus, nativeCurrencyStatus: CryptoCurrencyStatus,
initialFee: Fee.Ethereum, initialFee: Fee.Ethereum,
isYieldActive: Boolean = false,
userWallet: UserWallet? = null,
): Either<GetFeeError, TransactionFeeExtended> { ): Either<GetFeeError, TransactionFeeExtended> {
return either { return either {
// fast finish to skip calculations if no funds in token // fast finish to skip calculations if no funds in token.
if (tokenForPayFeeStatus.value.amount?.isZero() == true) { // Skipped on the yield path: a zero plain balance is expected — it will be topped up from yield.
if (!isYieldActive && tokenForPayFeeStatus.value.amount?.isZero() == true) {
raise(GaslessError.NotEnoughFunds) raise(GaslessError.NotEnoughFunds)
} }
@ -120,23 +128,16 @@ internal class TokenFeeCalculator(
), ),
) )
val feeTransferGasLimit = when (feeTransferGasLimitResult) { val feeTransferGasLimit = resolveFeeTransferGasLimit(feeTransferGasLimitResult, isYieldActive)
is Result.Success -> feeTransferGasLimitResult.data
is Result.Failure -> {
// If there is a dust on the balance, the gas limit estimation will fail with code
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
val cause = feeTransferGasLimitResult.error.cause
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
raise(GaslessError.NotEnoughFunds)
}
}
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
}.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas val withdrawGas = if (isYieldActive) {
estimateWithdrawGasLimit(userWallet, walletManager, tokenForPayFee)
} else {
BigInteger.ZERO
}
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + withdrawGas
val maxFeePerGas = when (initialFee) { val maxFeePerGas = when (initialFee) {
is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas
@ -170,7 +171,8 @@ internal class TokenFeeCalculator(
) )
val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO
if (tokenBalance < feeInTokenCurrency) { // Skipped on the yield path: ResolveGaslessFeePlanUseCase decides plain-vs-yield coverage.
if (!isYieldActive && tokenBalance < feeInTokenCurrency) {
raise(GaslessError.NotEnoughFunds) raise(GaslessError.NotEnoughFunds)
} }
@ -186,10 +188,97 @@ internal class TokenFeeCalculator(
TransactionFeeExtended( TransactionFeeExtended(
transactionFee = TransactionFee.Single(normal = fee), transactionFee = TransactionFee.Single(normal = fee),
feeTokenId = tokenForPayFee.id, feeTokenId = tokenForPayFee.id,
// Per-call gas limits for the v2 gasless meta-tx (bound into the EIP-712 hash).
// Main = the user's transaction execution gas; withdraw = the appended yield-withdraw
// sub-call gas, present only on the yield path where a batch is built.
mainTransactionGasLimit = initialFee.gasLimit,
withdrawGasLimit = withdrawGas.takeIf { isYieldActive },
) )
} }
} }
/**
* Resolves the fee-transfer gas limit from the on-chain estimation result.
*
* On the yield path ([isYieldActive] = true), when the estimation reverts with
* [BlockchainSdkError.Ethereum.InsufficientFundsForOperation] (expected for a zero plain balance),
* falls back to [FALLBACK_FEE_TRANSFER_GAS_LIMIT] instead of raising [GaslessError.NotEnoughFunds].
* All other failures propagate as [GaslessError.DataError] on both paths.
*/
private fun Raise<GetFeeError>.resolveFeeTransferGasLimit(
feeTransferGasLimitResult: Result<BigInteger>,
isYieldActive: Boolean,
): BigInteger {
val rawFeeTransferGasLimit: BigInteger = when (feeTransferGasLimitResult) {
is Result.Success -> feeTransferGasLimitResult.data
is Result.Failure -> {
// If there is a dust on the balance, the gas limit estimation will fail with code
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
val cause = feeTransferGasLimitResult.error.cause
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
if (isYieldActive) {
FALLBACK_FEE_TRANSFER_GAS_LIMIT
} else {
raise(GaslessError.NotEnoughFunds)
}
} else {
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
} else {
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
}
}
return rawFeeTransferGasLimit.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
}
@Suppress("SwallowedException")
private suspend fun estimateWithdrawGasLimit(
userWallet: UserWallet?,
walletManager: EthereumWalletManager,
token: CryptoCurrency.Token,
): BigInteger {
if (userWallet == null) return WITHDRAW_GAS_LIMIT
val moduleAddress = gaslessYieldRepository.getYieldContractAddress(userWallet.walletId, token)
?: return WITHDRAW_GAS_LIMIT
// The withdraw amount is encoded into the call data: a small fixed probe whose exact value does not
// affect the gas cost. It is a token amount because the call data needs the token's contract/decimals.
val withdrawAmount = createTokenAmount(
token = token,
value = BigDecimal(PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS).movePointLeft(token.decimals),
)
val probeCallData = try {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = userWallet.walletId,
cryptoCurrency = token,
amount = withdrawAmount,
)
} catch (e: YieldModuleUpgradeUnavailableException) {
return WITHDRAW_GAS_LIMIT
} catch (e: YieldModuleVersionIndeterminateException) {
return WITHDRAW_GAS_LIMIT
}
// Mirrors the real batch sub-call (see CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload):
// `to = moduleAddress`, zero native value, withdraw call data. A zero-value Coin amount is required so
// that EthereumWalletManager.getGasLimit keeps `to` = moduleAddress — a Token amount would override it
// with the token contract address and estimate the wrong call.
val estimationAmount = Amount(
currencySymbol = token.symbol,
value = BigDecimal.ZERO,
decimals = token.decimals,
type = AmountType.Coin,
)
return when (val result = walletManager.getGasLimit(estimationAmount, moduleAddress, probeCallData)) {
is Result.Success -> result.data
is Result.Failure -> WITHDRAW_GAS_LIMIT
}
}
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount( private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
token = Token( token = Token(
symbol = token.symbol, symbol = token.symbol,
@ -217,6 +306,26 @@ internal class TokenFeeCalculator(
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10 const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
/**
* Fallback gas for the batch yield-withdraw operation (withdraw + possible module upgrade), used when
* the on-chain probe estimation in [estimateWithdrawGasLimit] is unavailable or reverts. Overestimate-safe
* because it only inflates maxTokenFee (a cap) and the signed per-call gas limit.
*/
val WITHDRAW_GAS_LIMIT: BigInteger = BigInteger("150000")
/**
* Probe amount (in the fee token's minimal units) for the `withdraw` gas estimation. Per spec it is a
* small fixed value: large enough to simulate a real withdraw, small enough not to exceed the yield
* balance. The withdraw gas cost is effectively independent of the amount.
*/
const val PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS = 10_000L
/**
* Fallback fee-transfer gas limit used when on-chain estimation reverts due to a zero plain balance on the
* yield path. TODO: tune against testnet if needs.
*/
val FALLBACK_FEE_TRANSFER_GAS_LIMIT: BigInteger = BigInteger("100000")
/** /**
* Increases BigDecimal value by specified percentage. * Increases BigDecimal value by specified percentage.
* *

View file

@ -0,0 +1,25 @@
package com.tangem.domain.transaction.models
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class GaslessBatchTransactionDataTest {
@Test
fun `holds transactions fee and nonce`() {
val tx = GaslessTransactionData.Transaction(
to = "0xabc", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(1),
)
val withdraw = GaslessTransactionData.Transaction(
to = "0xdef", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(2),
)
val fee = GaslessTransactionData.Fee(
feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv",
)
val batch = GaslessBatchTransactionData(transactions = listOf(tx, withdraw), fee = fee, nonce = BigInteger.ZERO)
assertThat(batch.transactions).hasSize(2)
assertThat(batch.transactions[1]).isEqualTo(withdraw)
}
}

View file

@ -0,0 +1,155 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.raise.either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.transaction.error.GetFeeError
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Unit tests for [computeSendAmountInFeeToken].
*
* Cases:
* (a) Different token ZERO (fee token sent token).
* (b) Same token via AmountType.Token the actual sent amount.
* (c) Same token via AmountType.TokenYieldSupply the actual sent amount.
* (d) Same token but amount.value == null raises (loud error, never silent ZERO).
* (e) Compiled tx raises (gasless token-fee requires uncompiled data).
*/
class ComputeSendAmountInFeeTokenTest {
private val feeContract = "0xUSDC"
private val otherContract = "0xDAI"
private val sentAmount = BigDecimal("50.0")
private fun makeToken(contract: String) = Token(
name = "TestToken",
symbol = "TST",
contractAddress = contract,
decimals = 6,
)
private fun uncompiledWith(type: AmountType, value: BigDecimal?) = TransactionData.Uncompiled(
amount = Amount(
currencySymbol = "TST",
value = value,
maxValue = null,
decimals = 6,
type = type,
),
sourceAddress = "0xSrc",
destinationAddress = "0xDst",
fee = null,
)
// (a) Sent token is different from fee token → ZERO
@Test
fun `returns ZERO when sent token differs from fee token`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(otherContract)),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(BigDecimal.ZERO, result.getOrNull())
}
// (b) AmountType.Token — same contract as fee token → returns the sent amount
@Test
fun `returns sent amount when AmountType Token matches fee token contract`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract)),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (b) Case-insensitive contract address match
@Test
fun `contract address comparison is case-insensitive`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract.uppercase())),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract.lowercase())
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (c) AmountType.TokenYieldSupply — same contract as fee token → returns the sent amount
@Test
fun `returns sent amount when AmountType TokenYieldSupply matches fee token contract`() {
val tx = uncompiledWith(
type = AmountType.TokenYieldSupply(
token = makeToken(feeContract),
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (d) Same token but amount.value == null → raises (never silently under-accounts as ZERO)
@Test
fun `raises when same token is sent but amount value is null`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract)),
value = null,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isLeft(), "Expected Left (error) when sent amount is null")
assertTrue(
result.leftOrNull() is GetFeeError.DataError,
"Expected GetFeeError.DataError wrapping IllegalStateException",
)
}
// (e) Compiled tx → raises (gasless token-fee requires uncompiled data)
@Test
fun `raises when transactionData is Compiled`() {
val compiled = TransactionData.Compiled(
value = TransactionData.Compiled.Data.Bytes(byteArrayOf(0x01, 0x02)),
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(compiled, feeContract)
}
assertTrue(result.isLeft(), "Expected Left (error) for compiled tx")
assertTrue(
result.leftOrNull() is GetFeeError.DataError,
"Expected GetFeeError.DataError wrapping IllegalStateException",
)
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* Unit tests for [CreateAndSendGaslessTransactionUseCase.getDestinationAddress] resolves the on-chain
* `to` of the user's main gasless sub-call.
*
* Regression guard: a yield-supply send must target the user's yield MODULE (the contract that
* runs `send(token, dest, amount)`), not the transfer recipient. Targeting the recipient reverts the whole
* batch with GAS_ESTIMATION_FAILED / require(false).
*/
internal class CreateAndSendGaslessDestinationAddressTest {
private val module = "0xmodule"
private val recipient = "0xrecipient"
private val tokenContract = "0xtokencontract"
private fun uncompiled(
destinationAddress: String,
extras: EthereumTransactionExtras?,
contractAddress: String?,
) = TransactionData.Uncompiled(
amount = mockk(relaxed = true),
fee = null,
sourceAddress = "0xsource",
destinationAddress = destinationAddress,
extras = extras,
contractAddress = contractAddress,
)
@Test
fun `GIVEN yield-supply send WHEN getDestinationAddress THEN returns module not recipient`() {
// Arrange — destinationAddress is patched to the yield module; the recipient lives inside the callData
val yieldCallData = EthereumYieldSupplySendCallData(
tokenContractAddress = tokenContract,
destinationAddress = recipient,
amount = mockk(relaxed = true),
)
val txData = uncompiled(
destinationAddress = module,
extras = EthereumTransactionExtras(callData = yieldCallData),
contractAddress = tokenContract,
)
// Act
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
// Assert
assertThat(to).isEqualTo(module)
}
@Test
fun `GIVEN ERC20 transfer WHEN getDestinationAddress THEN returns token contract`() {
// Arrange — a non-yield callData; `to` must be the token contract, not the recipient
val erc20CallData = object : SmartContractCallData {
override val methodId = "0xa9059cbb"
override val data = byteArrayOf(0x01)
override fun validate(blockchain: Blockchain) = true
}
val txData = uncompiled(
destinationAddress = recipient,
extras = EthereumTransactionExtras(callData = erc20CallData),
contractAddress = tokenContract,
)
// Act
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
// Assert
assertThat(to).isEqualTo(tokenContract)
}
@Test
fun `GIVEN non-yield tx without contract address WHEN getDestinationAddress THEN throws`() {
// Arrange
val txData = uncompiled(
destinationAddress = recipient,
extras = null,
contractAddress = null,
)
// Act & Assert
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
}
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessFeePlan
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase.GaslessPayload
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.math.BigInteger
/**
* Unit tests for [CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload].
* Pure function no coroutines or SDK side-effects.
*/
internal class CreateAndSendGaslessPayloadTest {
// ─── Common fixtures ─────────────────────────────────────────────────────────
private val mainTx = GaslessTransactionData.Transaction(
to = "0xmain",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x01, 0x02),
)
private val withdrawGasLimit = BigInteger.valueOf(150_000)
private val feeObj = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(21_000),
feeReceiver = "0xrecv",
)
private val nonce = BigInteger.valueOf(42)
// Minimal SmartContractCallData fake — only `data` is consumed by the SUT.
private val fakeWithdrawCallData = object : SmartContractCallData {
override val methodId: String = "0xfakeid"
override val data: ByteArray = byteArrayOf(0x12, 0x34)
override fun validate(blockchain: com.tangem.blockchain.common.Blockchain) = true
}
private val fakeToken: CryptoCurrency.Token = mockk(relaxed = true)
private val fakeTokenFee: Fee.Ethereum.TokenCurrency = mockk(relaxed = true)
private val fakeNativeFee: Fee = mockk(relaxed = true)
// ─── Case 1: TokenPayWithYieldWithdraw → GaslessPayload.Batch ────────────────
@Test
fun `TokenPayWithYieldWithdraw plan returns Batch with correct structure`() {
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = fakeToken,
fee = fakeTokenFee,
withdrawAmount = BigInteger.valueOf(7_000_001),
withdrawCallData = fakeWithdrawCallData,
yieldModuleAddress = "0xmodule",
)
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = withdrawGasLimit,
)
assertThat(result).isInstanceOf(GaslessPayload.Batch::class.java)
val batch = (result as GaslessPayload.Batch).data
// transactions list has exactly 2 entries
assertThat(batch.transactions).hasSize(2)
// index 0 is the unchanged main transaction
assertThat(batch.transactions[0]).isEqualTo(mainTx)
// index 1 is the yield-withdraw transaction
val withdrawTx = batch.transactions[1]
assertThat(withdrawTx.to).isEqualTo(plan.yieldModuleAddress)
assertThat(withdrawTx.value).isEqualTo(BigInteger.ZERO)
assertThat(withdrawTx.gasLimit).isEqualTo(withdrawGasLimit)
assertThat(withdrawTx.data).isEqualTo(fakeWithdrawCallData.data)
// fee and nonce are carried through
assertThat(batch.fee).isEqualTo(feeObj)
assertThat(batch.nonce).isEqualTo(nonce)
}
// ─── Case 2: TokenPay → GaslessPayload.Single ────────────────────────────────
@Test
fun `TokenPay plan returns Single wrapping mainTx feeObj and nonce`() {
val plan = GaslessFeePlan.TokenPay(feeToken = fakeToken, fee = fakeTokenFee)
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
val single = (result as GaslessPayload.Single).data
assertThat(single.transaction).isEqualTo(mainTx)
assertThat(single.fee).isEqualTo(feeObj)
assertThat(single.nonce).isEqualTo(nonce)
}
// ─── Case 3: null plan → GaslessPayload.Single (same as TokenPay) ───────────
@Test
fun `null plan returns Single wrapping mainTx feeObj and nonce`() {
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = null,
withdrawGasLimit = null,
)
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
val single = (result as GaslessPayload.Single).data
assertThat(single.transaction).isEqualTo(mainTx)
assertThat(single.fee).isEqualTo(feeObj)
assertThat(single.nonce).isEqualTo(nonce)
}
// ─── Case 4: NativePay → throws IllegalStateException ───────────────────────
@Test
fun `NativePay plan throws IllegalStateException`() {
val plan = GaslessFeePlan.NativePay(fee = fakeNativeFee)
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
}
}
// ─── Case 5: yield-withdraw plan without a withdraw gas limit → throws ────────
@Test
fun `TokenPayWithYieldWithdraw plan without withdrawGasLimit throws IllegalStateException`() {
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = fakeToken,
fee = fakeTokenFee,
withdrawAmount = BigInteger.valueOf(7_000_001),
withdrawCallData = fakeWithdrawCallData,
yieldModuleAddress = "0xmodule",
)
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
}
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONObject
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class Eip712TypedDataBuilderBatchTest {
@Test
fun `buildBatch emits GaslessBatchTransaction primary type with transactions array`() {
val tx = GaslessTransactionData.Transaction(
to = "0xaaa", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(0x12),
)
val withdraw = GaslessTransactionData.Transaction(
to = "0xbbb", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(0x34),
)
val fee = GaslessTransactionData.Fee(
feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv",
)
val batch = GaslessBatchTransactionData(listOf(tx, withdraw), fee, BigInteger.ZERO)
val json = JSONObject(Eip712TypedDataBuilder.buildBatch(batch, chainId = 1, verifyingContract = "0xuser"))
assertThat(json.getString("primaryType")).isEqualTo("GaslessBatchTransaction")
val message = json.getJSONObject("message")
assertThat(message.getJSONArray("transactions").length()).isEqualTo(2)
assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("to")).isEqualTo("0xbbb")
// v2: each sub-call carries its per-call gasLimit in the message
assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("gasLimit")).isEqualTo("150000")
val types = json.getJSONObject("types").getJSONArray("GaslessBatchTransaction")
assertThat(types.getJSONObject(0).getString("type")).isEqualTo("Transaction[]")
// v2: the Transaction struct adds gasLimit between value and data
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder()
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONObject
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class Eip712TypedDataBuilderTest {
@Test
fun `build emits GaslessTransaction primary type with per-call gasLimit in type and message`() {
// Arrange
val gaslessTransaction = GaslessTransactionData(
transaction = GaslessTransactionData.Transaction(
to = "0xaaa",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x12, 0x34),
),
fee = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(60_000),
feeReceiver = "0xrecv",
),
nonce = BigInteger.ZERO,
)
// Act
val json = JSONObject(
Eip712TypedDataBuilder.build(gaslessTransaction, chainId = 137, verifyingContract = "0xuser"),
)
// Assert
assertThat(json.getString("primaryType")).isEqualTo("GaslessTransaction")
// v2: the single transaction carries its per-call gasLimit in the message
val txMessage = json.getJSONObject("message").getJSONObject("transaction")
assertThat(txMessage.getString("gasLimit")).isEqualTo("120000")
// v2: the Transaction struct adds gasLimit between value and data (order defines the EIP-712 typehash)
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder()
// Domain is unchanged between v1/v2; verifyingContract is the user's EOA address
val domain = json.getJSONObject("domain")
assertThat(domain.getString("name")).isEqualTo("Tangem7702GaslessExecutor")
assertThat(domain.getString("version")).isEqualTo("1")
assertThat(domain.getString("verifyingContract")).isEqualTo("0xuser")
}
@Test
fun `build with includeGasLimit false omits gasLimit reproducing the v1 typehash`() {
// Arrange
val gaslessTransaction = GaslessTransactionData(
transaction = GaslessTransactionData.Transaction(
to = "0xaaa",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x12, 0x34),
),
fee = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(60_000),
feeReceiver = "0xrecv",
),
nonce = BigInteger.ZERO,
)
// Act — v1 mode (feature flag off)
val json = JSONObject(
Eip712TypedDataBuilder.build(
gaslessTransaction = gaslessTransaction,
chainId = 137,
verifyingContract = "0xuser",
includeGasLimit = false,
),
)
// Assert: the Transaction struct is the legacy {to, value, data} — gasLimit drives the typehash, so its
// absence reproduces exactly the v1 hash the current develop signs.
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "data").inOrder()
// and the message carries no gasLimit
val txMessage = json.getJSONObject("message").getJSONObject("transaction")
assertThat(txMessage.has("gasLimit")).isFalse()
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase.Companion.isEligibleFeeToken
import com.tangem.test.core.ProvideTestModels
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetAvailableFeeTokensUseCaseTest {
@ParameterizedTest
@ProvideTestModels
fun isEligible(model: EligibilityModel) {
// Arrange
val status = createStatus(model.yieldSupplyStatus)
// Act
val actual = isEligibleFeeToken(status, isYieldWithdrawEnabled = model.isYieldWithdrawEnabled)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
// Plain token (no yield status) is always eligible, regardless of the toggle.
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = false, expected = true),
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = true, expected = true),
// Active yield: eligible only when gasless v2 (yield withdraw) is enabled.
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = true),
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
// Inactive yield status: excluded either way (no module to withdraw from).
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = false),
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
)
internal data class EligibilityModel(
val yieldSupplyStatus: YieldSupplyStatus?,
val isYieldWithdrawEnabled: Boolean,
val expected: Boolean,
)
private fun createStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
val status = mockk<CryptoCurrencyStatus>()
every { status.value.yieldSupplyStatus } returns yieldSupplyStatus
return status
}
private companion object {
val ACTIVE_YIELD = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val INACTIVE_YIELD = ACTIVE_YIELD.copy(isActive = false)
}
}

View file

@ -0,0 +1,425 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.GaslessFeePlan
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
/**
* Unit tests for [ResolveGaslessFeePlanUseCase].
* Covers every branch of the gasless fee decision tree.
*/
internal class ResolveGaslessFeePlanUseCaseTest {
private lateinit var gaslessYieldRepository: GaslessYieldRepository
private lateinit var useCase: ResolveGaslessFeePlanUseCase
private val mockUserWalletId: UserWalletId = mockk(relaxed = true)
private val mockUserWallet: UserWallet = mockk<UserWallet.Hot>().also {
every { it.walletId } returns mockUserWalletId
}
@BeforeEach
fun setup() {
gaslessYieldRepository = mockk()
useCase = ResolveGaslessFeePlanUseCase(gaslessYieldRepository)
}
// ─── Case 1: plain balance >= required → TokenPay ──────────────────────────
@Test
fun `plain balance covers fee returns TokenPay`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull()
assertThat(plan).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
assertThat((plan as GaslessFeePlan.TokenPay).fee).isEqualTo(tokenFee)
}
@Test
fun `plain balance equals required returns TokenPay`() = runTest {
val amount = BigDecimal("5")
val tokenStatus = tokenStatus(plainBalance = amount, decimals = 6)
val tokenFee = tokenFee(feeAmount = amount, decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
}
// ─── Case 2: yield-active with no liquid → the whole fee is withdrawn from the module ──
@Test
fun `yield active with no liquid withdraws the whole fee`() = runTest {
val decimals = 6
// value.amount is effectiveBalance = liquid(EOA) + effectiveProtocolBalance. Here total == module
// balance (20), so liquid is 0 and the entire fee must be withdrawn from the module — the plan must
// not short-circuit to TokenPay.
// withdraw == feeAmount, CEILING-rounded: 10000000.5 → 10000001 (floor would give 10000000).
val feeAmount = BigDecimal("10.0000005")
val moduleBalance = BigDecimal("20")
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger()
val floorAmount = feeAmount.movePointRight(decimals).toBigInteger() // 10000000
assertThat(expectedWithdrawAmount).isGreaterThan(floorAmount)
// value.amount == module balance → liquid is 0, so the fee cannot be paid from the EOA (no TokenPay).
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = mockUserWalletId,
cryptoCurrency = any(),
amount = any(),
)
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
// Must be 10000001 (CEILING of the fee), not the module balance and not floor.
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(10_000_001))
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
}
// ─── Case 2b: send amount counts toward sufficiency but NOT toward the withdraw ────────────
@Test
fun `yield active withdraw covers only the fee not the send amount`() = runTest {
val decimals = 6
// The main module.send tx moves the send amount from the module itself, so the fee-withdraw must
// cover ONLY the fee. Including the send amount would withdraw it twice and overdraw the module.
val feeAmount = BigDecimal("3.0")
val sendAmountInFeeToken = BigDecimal("1.5")
val moduleBalance = BigDecimal("5.0") // covers required = fee(3.0) + send(1.5) = 4.5 ✓
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger() // 3000000 — the FEE only, NOT 4.5
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = mockUserWalletId,
cryptoCurrency = any(),
amount = any(),
)
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = sendAmountInFeeToken,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(3_000_000))
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
}
// ─── Case 2c: module cannot cover send + fee → NotEnoughFunds ──────────────
@Test
fun `yield active module cannot cover send plus fee returns NotEnoughFunds`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("4"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("3"), decimals = 6)
// required = fee(3) + send(1.5) = 4.5, but the module holds only 4.0
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("4.0")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal("1.5"),
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 3: plain insufficient, isYieldActive=false → NotEnoughFunds ──────
@Test
fun `plain insufficient yield inactive returns NotEnoughFunds`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("1"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 4: YieldModuleUpgradeUnavailableException → ModuleUpdateUnavailable
@Test
fun `createPartialWithdrawCallData throws UpgradeUnavailableException returns ModuleUpdateUnavailable`() = runTest {
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("10")
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
} throws YieldModuleUpgradeUnavailableException("0xold")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
}
// ─── Case 5: plain + yield < required → NotEnoughFunds ─────────────────────
@Test
fun `plain plus yield insufficient returns NotEnoughFunds`() = runTest {
// total(6) = liquid(1) + module(5) < fee(10) → not enough funds anywhere.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("6"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("10"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("5") // liquid 1 + module 5 = 6 < 10
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 6: YieldModuleVersionIndeterminateException → ModuleUpdateUnavailable
@Test
fun `createPartialWithdrawCallData throws VersionIndeterminateException returns ModuleUpdateUnavailable`() = runTest {
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("10")
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
} throws YieldModuleVersionIndeterminateException("rpc error")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
}
// ─── Case 7: liquid EOA balance covers most of send+fee, protocol alone does not ──────────────
@Test
fun `GIVEN liquid covers send but protocol alone does not WHEN yield active THEN TokenPayWithYieldWithdraw`() =
runTest {
// value.amount is effectiveBalance (liquid EOA + effectiveProtocolBalance). The user sends 3.00 of
// 3.585624 total. The yield module (effectiveProtocolBalance) holds only 0.6, the rest (2.985624)
// is liquid on the EOA. required = send(3.00) + fee(0.05) = 3.05 < total(3.585624), so funds ARE
// sufficient. The old check compared the module balance (0.6) against required and wrongly raised
// NotEnoughFunds.
val decimals = 6
val totalBalance = BigDecimal("3.585624")
val moduleBalance = BigDecimal("0.6")
val feeAmount = BigDecimal("0.05")
val sendAmount = BigDecimal("3.00")
// module.send consumes EOA liquid first, leaving 0 for the fee, so the whole fee must be withdrawn.
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger()
val tokenStatus = tokenStatus(plainBalance = totalBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
// Act
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = sendAmount,
)
// Assert
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
}
// ─── Case 8: liquid EOA balance alone covers send + fee → no withdraw needed ───────────────────
@Test
fun `GIVEN liquid covers send plus fee WHEN yield active THEN TokenPay without withdraw`() = runTest {
// Arrange — liquid = total(10) - module(2) = 8, which already covers required = send(3) + fee(1) = 4.
// The EOA holds enough after the main send to settle the fee, so no yield withdraw is needed.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("1"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("2")
// Act
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal("3"),
)
// Assert
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
}
// ─── Helpers ────────────────────────────────────────────────────────────────
private fun tokenStatus(
plainBalance: BigDecimal = BigDecimal("100"),
decimals: Int = 6,
): CryptoCurrencyStatus {
val token = mockk<CryptoCurrency.Token>(relaxed = true)
every { token.symbol } returns "USDC"
every { token.contractAddress } returns "0xUSDC"
every { token.decimals } returns decimals
val status = mockk<CryptoCurrencyStatus>()
every { status.currency } returns token
every { status.value.amount } returns plainBalance
return status
}
private fun tokenFee(feeAmount: BigDecimal, decimals: Int = 6): Fee.Ethereum.TokenCurrency {
val blockchainToken = Token(symbol = "USDC", contractAddress = "0xUSDC", decimals = decimals)
val amount = Amount(token = blockchainToken, value = feeAmount)
return Fee.Ethereum.TokenCurrency(
amount = amount,
gasLimit = BigInteger("100000"),
coinPriceInToken = BigInteger("2000000000"),
feeTransferGasLimit = BigInteger("60000"),
baseGas = BigInteger("21000"),
)
}
}

View file

@ -14,7 +14,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.coEvery import io.mockk.coEvery
@ -36,6 +39,7 @@ class TokenFeeCalculatorTest {
private lateinit var walletManagersFacade: WalletManagersFacade private lateinit var walletManagersFacade: WalletManagersFacade
private lateinit var gaslessTransactionRepository: GaslessTransactionRepository private lateinit var gaslessTransactionRepository: GaslessTransactionRepository
private lateinit var gaslessYieldRepository: GaslessYieldRepository
private lateinit var demoConfig: DemoConfig private lateinit var demoConfig: DemoConfig
private lateinit var tokenFeeCalculator: TokenFeeCalculator private lateinit var tokenFeeCalculator: TokenFeeCalculator
@ -49,12 +53,14 @@ class TokenFeeCalculatorTest {
fun setup() { fun setup() {
walletManagersFacade = mockk() walletManagersFacade = mockk()
gaslessTransactionRepository = mockk() gaslessTransactionRepository = mockk()
gaslessYieldRepository = mockk()
demoConfig = mockk() demoConfig = mockk()
tokenFeeCalculator = TokenFeeCalculator( tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade, walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository, gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig, demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
) )
mockWalletManager = mockk() mockWalletManager = mockk()
@ -215,6 +221,9 @@ class TokenFeeCalculatorTest {
assertNotNull(feeExtended) assertNotNull(feeExtended)
assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId) assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId)
assertTrue(feeExtended.transactionFee is TransactionFee.Single) assertTrue(feeExtended.transactionFee is TransactionFee.Single)
// main-tx per-call gas = initialFee.gasLimit; no withdraw on the non-yield path
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertNull(feeExtended.withdrawGasLimit)
} }
} }
@ -413,6 +422,283 @@ class TokenFeeCalculatorTest {
} }
} }
// ===== Yield-path Tests =====
/**
* With active yield, a token whose plain balance is small (not enough to pay the fee on its own) must NOT
* raise NotEnoughFunds the resolver decides coverage. The gas limit must include the extra withdraw gas.
*
* Here `userWallet` is not passed (null), so the withdraw gas estimation is skipped and the
* deterministic fallback [WITHDRAW_GAS_LIMIT] is used.
*
* Expected gasLimit breakdown (matching companion constants):
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
* baseGas = 21_000
* WITHDRAW_GAS_LIMIT = 150_000
* total = 337_000
*/
@Test
fun `calculateTokenFee with active yield but no wallet falls back to WITHDRAW_GAS_LIMIT`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"), // yield covers the rest
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"), // tiny plain balance — insufficient on its own
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
)
// Then
assertTrue(result.isRight(), "Expected success on yield path with small plain balance")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 = 337_000
assertEquals(BigInteger("337000"), fee.gasLimit, "gasLimit must include WITHDRAW_GAS_LIMIT (150000)")
// feeTransferGasLimit stored in the fee object = 66_000
assertEquals(BigInteger("66000"), fee.feeTransferGasLimit, "feeTransferGasLimit = 60000 * 1.10")
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = WITHDRAW_GAS_LIMIT
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
}
}
/**
* With active yield, when getGasLimit reverts due to zero plain balance
* (BlockchainSdkError.Ethereum.InsufficientFundsForOperation wrapped in WrappedThrowable),
* calculateTokenFee must use the deterministic FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) instead of raising.
*
* Expected breakdown:
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 100_000 * 1.10 = 110_000 (FALLBACK_FEE_TRANSFER_GAS_LIMIT * 1.10)
* baseGas = 21_000
* WITHDRAW_GAS_LIMIT = 150_000
* total gasLimit = 381_000
*/
@Test
fun `calculateTokenFee with active yield uses fallback gas when transfer estimation reverts with insufficient funds`() =
runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
// Zero plain balance — exactly the condition that causes estimation revert
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
// Simulate on-chain estimation reverting with InsufficientFundsForOperation
val insufficientFundsException =
BlockchainSdkError.Ethereum.InsufficientFundsForOperation("insufficient funds for gas")
val wrappedError = BlockchainSdkError.WrappedThrowable(insufficientFundsException)
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Failure(wrappedError)
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
)
// Then
assertTrue(result.isRight(), "Expected success with fallback gas on yield path")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// feeTransferGasLimit = FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) * 1.10 = 110_000
assertEquals(
BigInteger("110000"),
fee.feeTransferGasLimit,
"feeTransferGasLimit must use fallback (100000 * 1.10 = 110000)",
)
// gasLimit = 100_000 + 110_000 + 21_000 + 150_000 = 381_000
assertEquals(
BigInteger("381000"),
fee.gasLimit,
"gasLimit must include WITHDRAW_GAS_LIMIT (150000)",
)
}
}
/**
* Confirms that the non-yield path (isYieldActive = false, default) is unchanged:
* a token with insufficient plain balance still raises NotEnoughFunds.
*/
@Test
fun `calculateTokenFee without yield still raises NotEnoughFunds on insufficient balance`() = runTest {
// Given
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"), // very small — insufficient
fiatRate = BigDecimal("1"),
)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee()
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When — default isYieldActive = false
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isLeft(), "Non-yield path must still raise NotEnoughFunds for insufficient balance")
result.onLeft { error ->
assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds)
}
}
/**
* With active yield AND a wallet, the withdraw gas limit is estimated on-chain via a probe
* `withdraw(yieldToken, 10000)` against the yield module. The estimated value (here 200_000) flows into
* BOTH the maxTokenFee cap and the signed per-call withdraw gas limit not the hardcoded fallback.
*
* Expected gasLimit breakdown:
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
* baseGas = 21_000
* estimated withdraw = 200_000
* total = 387_000
*/
@Test
fun `calculateTokenFee with active yield and wallet estimates withdraw gas on-chain`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// fee-transfer estimation (to the fee receiver) vs. withdraw estimation (to the yield module)
coEvery {
mockWalletManager.getGasLimit(any(), "0xFeeReceiver", any())
} returns Result.Success(BigInteger("60000"))
coEvery {
mockWalletManager.getGasLimit(any(), "0xModule", any())
} returns Result.Success(BigInteger("200000"))
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xModule"
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
} returns mockk<SmartContractCallData>(relaxed = true)
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
userWallet = mockUserWallet,
)
// Then
assertTrue(result.isRight(), "Expected success on yield path with on-chain withdraw estimation")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// gasLimit = 100_000 + 66_000 + 21_000 + 200_000 = 387_000
assertEquals(BigInteger("387000"), fee.gasLimit, "gasLimit must include the estimated withdraw gas")
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = estimated 200_000
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertEquals(BigInteger("200000"), feeExtended.withdrawGasLimit)
}
coVerify { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) }
coVerify { mockWalletManager.getGasLimit(any(), "0xModule", any()) }
}
/**
* When the yield module address is unavailable (e.g. module not yet deployed), the on-chain estimation
* is skipped and the calculator falls back to [WITHDRAW_GAS_LIMIT] even though a wallet is provided.
*/
@Test
fun `calculateTokenFee with active yield falls back when yield module address is unavailable`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee()
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
coEvery { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) } returns null
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
userWallet = mockUserWallet,
)
// Then
assertTrue(result.isRight())
result.onRight { feeExtended ->
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 (fallback) = 337_000
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
assertEquals(BigInteger("337000"), fee.gasLimit)
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
}
// withdraw estimation must NOT be attempted without a module address
coVerify(exactly = 0) { gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) }
}
// ===== Helper Methods ===== // ===== Helper Methods =====
private fun createMockTransactionFee(): TransactionFee { private fun createMockTransactionFee(): TransactionFee {
@ -455,6 +741,21 @@ class TokenFeeCalculatorTest {
return status return status
} }
/**
* Returns a copy of this [CryptoCurrencyStatus] mock with [yieldSupplyStatus] overridden.
* Since [CryptoCurrencyStatus] is a mockk, we create a new mock that delegates everything and
* overrides only [yieldSupplyStatus].
*/
private fun CryptoCurrencyStatus.withYieldSupplyStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
val original = this
val newStatus = mockk<CryptoCurrencyStatus>()
every { newStatus.currency } returns original.currency
every { newStatus.value.amount } returns original.value.amount
every { newStatus.value.fiatRate } returns original.value.fiatRate
every { newStatus.value.yieldSupplyStatus } returns yieldSupplyStatus
return newStatus
}
private fun createMockNativeCurrencyStatus( private fun createMockNativeCurrencyStatus(
fiatRate: BigDecimal? = BigDecimal("2000"), fiatRate: BigDecimal? = BigDecimal("2000"),
decimals: Int = 18, decimals: Int = 18,
@ -471,4 +772,4 @@ class TokenFeeCalculatorTest {
return status return status
} }
} }

View file

@ -3,13 +3,14 @@ package com.tangem.domain.yield.supply
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.GaslessYieldRepository
import java.math.BigDecimal import java.math.BigDecimal
interface YieldSupplyTransactionRepository { interface YieldSupplyTransactionRepository : GaslessYieldRepository {
suspend fun createEnterTransactions( suspend fun createEnterTransactions(
userWalletId: UserWalletId, userWalletId: UserWalletId,
@ -23,10 +24,6 @@ interface YieldSupplyTransactionRepository {
fee: Fee?, fee: Fee?,
): TransactionData.Uncompiled ): TransactionData.Uncompiled
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
/** /**
* Checks the version status of the user's yield-module contract and wraps [callData] with an * Checks the version status of the user's yield-module contract and wraps [callData] with an
* upgrade transaction if the deployed version is out of date. * upgrade transaction if the deployed version is out of date.
@ -36,4 +33,7 @@ interface YieldSupplyTransactionRepository {
network: Network, network: Network,
callData: SmartContractCallData, callData: SmartContractCallData,
): SmartContractCallData ): SmartContractCallData
/** Returns the on-chain version status of the user's yield module for [network]. */
suspend fun getYieldModuleVersionStatus(userWalletId: UserWalletId, network: Network): YieldModuleVersionStatus
} }

View file

@ -1,267 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.exchange
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Deprecated("Use ExpressStatusBlock from common")
@Composable
internal fun ExchangeStatusBlock(
statuses: ImmutableList<ExchangeStatusState>,
showLink: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing12,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing16),
) {
Text(
text = stringResourceSafe(id = R.string.express_exchange_status_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerWMax()
AnimatedVisibility(visible = showLink) {
Row(
modifier = Modifier.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_top_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.spacing16)
.padding(end = TangemTheme.dimens.spacing2),
)
Text(
text = stringResourceSafe(id = R.string.common_go_to_provider),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
AnimatedContent(targetState = statuses.lastIndex, label = "Exchange Status List Change") {
Column {
statuses.forEachIndexed { index, item ->
ExchangeStatusStep(
stepStatus = item,
isLast = index == it,
)
}
}
}
}
}
@Composable
private fun ExchangeStatusStep(
stepStatus: ExchangeStatusState,
modifier: Modifier = Modifier,
isLast: Boolean = false,
) {
Row(modifier = modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = stepStatus,
label = "Exchange Step Change Success",
modifier = Modifier
.size(TangemTheme.dimens.size20),
) { state ->
when {
state.status == ExchangeStatus.Cancelled -> {
ExchangeStep(
iconRes = R.drawable.ic_close_24,
color = TangemTheme.colors.icon.warning,
isDone = false,
)
}
state.status.isFailed() ||
state.status == ExchangeStatus.Refunded ||
state.status == ExchangeStatus.Paused
-> {
ExchangeStep(
iconRes = R.drawable.ic_close_24,
color = TangemTheme.colors.icon.warning,
isDone = state.isDone,
)
}
state.status == ExchangeStatus.Verifying -> ExchangeStep(
iconRes = R.drawable.ic_exclamation_24,
color = TangemTheme.colors.icon.attention,
isDone = state.isDone,
)
state.isDone -> ExchangeStep(
iconRes = R.drawable.ic_check_24,
color = TangemTheme.colors.icon.primary1,
isDone = true,
)
state.isActive -> ExchangeStepInProgress()
else -> ExchangeStepDefault()
}
}
if (!isLast) {
ExchangeStepSeparator()
}
}
ExchangeStatusStepText(stepStatus)
}
}
@Composable
private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) {
val status = stepStatus.status
val textColor = when {
status == ExchangeStatus.Cancelled || status == ExchangeStatus.Refunded || status == ExchangeStatus.Paused -> {
TangemTheme.colors.icon.warning
}
status.isFailed() && !stepStatus.isDone -> TangemTheme.colors.icon.warning
status == ExchangeStatus.Verifying && !stepStatus.isDone -> TangemTheme.colors.icon.attention
stepStatus.isDone -> TangemTheme.colors.text.primary1
!stepStatus.isActive -> TangemTheme.colors.text.disabled
else -> TangemTheme.colors.text.primary1
}
Text(
text = stepStatus.text.resolveReference(),
style = TangemTheme.typography.body2,
color = textColor,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing12),
)
}
@Composable
private fun ExchangeStepDefault() {
Box(
modifier = Modifier
.border(
width = TangemTheme.dimens.size1_5,
color = TangemTheme.colors.field.focused,
shape = CircleShape,
)
.padding(TangemTheme.dimens.spacing2),
)
}
@Composable
private fun ExchangeStep(color: Color, @DrawableRes iconRes: Int, isDone: Boolean) {
val (iconColor, borderColor) = if (isDone) {
TangemTheme.colors.icon.primary1 to TangemTheme.colors.field.focused
} else {
color to color
}
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = iconColor,
modifier = Modifier
.border(
width = TangemTheme.dimens.size1_5,
color = borderColor,
shape = CircleShape,
)
.padding(TangemTheme.dimens.spacing2),
)
}
@Composable
private fun ExchangeStepInProgress() {
CircularProgressIndicator(
color = TangemTheme.colors.icon.primary1,
strokeWidth = TangemTheme.dimens.size2,
modifier = Modifier
.padding(TangemTheme.dimens.spacing2)
.size(TangemTheme.dimens.size14),
)
}
@Composable
private fun ExchangeStepSeparator() {
Box(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing2)
.size(
width = TangemTheme.dimens.size1_5,
height = TangemTheme.dimens.size10,
)
.background(
color = TangemTheme.colors.field.focused,
shape = CircleShape,
),
)
}
@Preview
@Composable
private fun Preview_ExchangeStatusBlock() {
val base = ExchangeStatusState(
status = ExchangeStatus.Failed,
text = resourceReference(id = R.string.express_exchange_status_failed),
isActive = true,
isDone = false,
)
TangemThemePreview {
ExchangeStatusBlock(
statuses = listOf(
base,
base.copy(isActive = false, isDone = false),
base.copy(isActive = true, isDone = false),
base.copy(isActive = true, isDone = true),
ExchangeStatusState(
status = ExchangeStatus.Paused,
text = resourceReference(id = R.string.express_exchange_status_paused),
isActive = true,
isDone = false,
),
)
.toImmutableList(),
showLink = false,
onClick = {},
)
}
}

View file

@ -14,7 +14,13 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.expressStatus.ExpressEstimate import com.tangem.common.ui.expressStatus.ExpressEstimate
import com.tangem.common.ui.expressStatus.ExpressHideButton import com.tangem.common.ui.expressStatus.ExpressHideButton
import com.tangem.common.ui.expressStatus.ExpressProvider import com.tangem.common.ui.expressStatus.ExpressProvider
import com.tangem.common.ui.expressStatus.ExpressStatusBlock
import com.tangem.common.ui.expressStatus.state.ExpressLinkUM
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemState
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemUM
import com.tangem.common.ui.expressStatus.state.ExpressStatusUM
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH10 import com.tangem.core.ui.components.SpacerH10
import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.SpacerH12
@ -27,7 +33,9 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
internal fun ExchangeStatusBottomSheetContent( internal fun ExchangeStatusBottomSheetContent(
@ -80,11 +88,7 @@ internal fun ExchangeStatusBottomSheetContent(
extraContent() extraContent()
SpacerH12() SpacerH12()
} }
ExchangeStatusBlock( ExpressStatusBlock(state = state.toExpressStatusUM())
statuses = state.statuses,
showLink = state.showProviderLink,
onClick = { state.info.onGoToProviderClick(state.info.txExternalUrl.orEmpty()) },
)
if (state.notification != null) { if (state.notification != null) {
Notification(state = state.notification, activeStatus = state.activeStatus) Notification(state = state.notification, activeStatus = state.activeStatus)
} }
@ -101,6 +105,34 @@ internal fun ExchangeStatusBottomSheetContent(
} }
} }
private fun ExchangeUM.toExpressStatusUM(): ExpressStatusUM = ExpressStatusUM(
title = resourceReference(R.string.express_exchange_status_title),
link = if (showProviderLink) {
ExpressLinkUM.Content(
icon = R.drawable.ic_arrow_top_right_24,
text = resourceReference(R.string.common_go_to_provider),
onClick = { info.onGoToProviderClick(info.txExternalUrl.orEmpty()) },
)
} else {
ExpressLinkUM.Empty
},
statuses = statuses.map { it.toExpressStatusItemUM() }.toImmutableList(),
)
private fun ExchangeStatusState.toExpressStatusItemUM(): ExpressStatusItemUM = ExpressStatusItemUM(
text = text,
state = when {
status == ExchangeStatus.Cancelled -> ExpressStatusItemState.Error
status.isFailed() || status == ExchangeStatus.Refunded || status == ExchangeStatus.Paused -> {
if (isDone) ExpressStatusItemState.Done else ExpressStatusItemState.Error
}
status == ExchangeStatus.Verifying -> ExpressStatusItemState.Warning
isDone -> ExpressStatusItemState.Done
isActive -> ExpressStatusItemState.Active
else -> ExpressStatusItemState.Default
},
)
@Composable @Composable
private fun Notification(state: ExchangeStatusNotification, activeStatus: ExchangeStatus?) { private fun Notification(state: ExchangeStatusNotification, activeStatus: ExchangeStatus?) {
AnimatedContent( AnimatedContent(