diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 57ab3eb8e5..890e24c01b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -6,9 +6,13 @@ import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository 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.GetDynamicReceiveAddressUseCase 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.SingleNetworkStatusSupplier import com.tangem.domain.notifications.repository.PushNotificationsRepository @@ -319,30 +323,50 @@ internal object TransactionDomainModule { gaslessTransactionRepository: GaslessTransactionRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + featureTogglesManager: FeatureTogglesManager, ): GetAvailableFeeTokensUseCase { return GetAvailableFeeTokensUseCase( singleAccountStatusListSupplier = singleAccountStatusListSupplier, gaslessTransactionRepository = gaslessTransactionRepository, 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 @Singleton fun provideGetFeeForGaslessUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, getFeeUseCase: GetFeeUseCase, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + featureTogglesManager: FeatureTogglesManager, ): GetFeeForGaslessUseCase { return GetFeeForGaslessUseCase( walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, singleAccountStatusListSupplier = singleAccountStatusListSupplier, getFeeUseCase = getFeeUseCase, currencyChecksRepository = currencyChecksRepository, + resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase, + isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -351,15 +375,23 @@ internal object TransactionDomainModule { fun provideGetFeeForTokenUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + featureTogglesManager: FeatureTogglesManager, ): GetFeeForTokenUseCase { return GetFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, singleAccountStatusListSupplier = singleAccountStatusListSupplier, currencyChecksRepository = currencyChecksRepository, + resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase, + isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -379,6 +411,7 @@ internal object TransactionDomainModule { singleAccountListSupplier: SingleAccountListSupplier, cardSdkConfigRepository: CardSdkConfigRepository, tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + featureTogglesManager: FeatureTogglesManager, ): CreateAndSendGaslessTransactionUseCase { return CreateAndSendGaslessTransactionUseCase( walletManagersFacade = walletManagersFacade, @@ -386,6 +419,9 @@ internal object TransactionDomainModule { gaslessTransactionRepository = gaslessTransactionRepository, cardSdkConfigRepository = cardSdkConfigRepository, getHotWalletSigner = tangemHotWalletSignerFactory::create, + isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -394,15 +430,21 @@ internal object TransactionDomainModule { fun provideEstimateFeeForTokenUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + featureTogglesManager: FeatureTogglesManager, ): EstimateFeeForTokenUseCase { return EstimateFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, singleAccountStatusListSupplier = singleAccountStatusListSupplier, currencyChecksRepository = currencyChecksRepository, + isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -411,12 +453,14 @@ internal object TransactionDomainModule { fun provideEstimateFeeForGaslessTxUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, estimateFeeUseCase: EstimateFeeUseCase, currencyChecksRepository: CurrencyChecksRepository, ): EstimateFeeForGaslessTxUseCase { return EstimateFeeForGaslessTxUseCase( gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, singleAccountStatusListSupplier = singleAccountStatusListSupplier, diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 343a439161..f0c256441f 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -151,6 +151,10 @@ "name": "TWI_83_ADDRESS_BOOK_ENABLED", "version": "undefined" }, + { + "name": "AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED", + "version": "undefined" + }, { "name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED", "version": "6.0" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApiV2.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApiV2.kt new file mode 100644 index 0000000000..ad24dea6b3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApiV2.kt @@ -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> + + @POST("api/v2/transaction/batch-sign") + suspend fun signGaslessBatchTransaction( + @Body transaction: GaslessBatchTransactionRequest, + ): ApiResponse> +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessBatchTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessBatchTransactionRequest.kt new file mode 100644 index 0000000000..350c074a4b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessBatchTransactionRequest.kt @@ -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, + + @Json(name = "fee") + val fee: FeeData, + + @Json(name = "nonce") + val nonce: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt index 034bb21097..9154f64e43 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt @@ -45,6 +45,9 @@ data class TransactionData( @Json(name = "value") val value: String, + @Json(name = "gasLimit") + val gasLimit: String? = null, + @Json(name = "data") val data: String, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 08c71a4cc5..f937877421 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -18,6 +18,7 @@ import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi 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.TangemPayAuthApi 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, + ), + ) + } } \ No newline at end of file diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index 4e8ca65edd..e926e9bd32 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(tangemDeps.card.core) /** Core */ + implementation(projects.core.configToggles) implementation(projects.core.datasource) implementation(projects.core.utils) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 2664312cb5..4e2c391429 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -3,17 +3,23 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.Token import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder +import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.gasless.GaslessTxServiceApi +import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2 import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex @@ -23,17 +29,19 @@ import java.math.BigInteger class DefaultGaslessTransactionRepository( private val gaslessTxServiceApi: GaslessTxServiceApi, + private val gaslessTxServiceApiV2: GaslessTxServiceApiV2, + private val isGaslessV2Enabled: Boolean, private val coroutineDispatcherProvider: CoroutineDispatcherProvider, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ) : GaslessTransactionRepository { private val supportedTokensState = MutableStateFlow>>(hashMapOf()) - private val allFeeRecipientAddress = mutableSetOf() - private val allAddressesMutex = Mutex() private val receiverAddressMutex = Mutex() private var feeReceiverAddress: String? = null - private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() + private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled) + private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter) + private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter) private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() override suspend fun getSupportedTokens(network: Network): Set { @@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository( eip7702Auth = eip7702Auth, ) - val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow() + val response = if (isGaslessV2Enabled) { + gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest) + } else { + gaslessTxServiceApi.signGaslessTransaction(transactionRequest) + }.getOrThrow() if (!response.isSuccess) { error("Gasless service returned unsuccessful response") @@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository( signedTransactionResultConverter.convert(response.result) } + override suspend fun signGaslessBatchTransaction( + gaslessBatchTransactionData: GaslessBatchTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) { + val blockchain = network.toBlockchain() + val transactionRequest = gaslessBatchTransactionRequestBuilder.build( + gaslessBatchTransaction = gaslessBatchTransactionData, + signature = signature, + userAddress = userAddress, + chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"), + eip7702Auth = eip7702Auth, + ) + + val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow() + + if (!response.isSuccess) { + error("Gasless service returned unsuccessful response") + } + + signedTransactionResultConverter.convert(response.result) + } + override fun getBaseGasForTransaction(): BigInteger { return BASE_GAS_FOR_TRANSACTION } @@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository( } override suspend fun getGaslessFeeAddresses(): Set { - return allAddressesMutex.withLock { - allFeeRecipientAddress.ifEmpty { - val allFeeAddresses = getAllFeeRecipientAddresses() - allFeeRecipientAddress.addAll(allFeeAddresses) - allFeeRecipientAddress - } - } - } - - private suspend fun getAllFeeRecipientAddresses(): Set { // TODO Replace with other backend call to get all fee recipient addresses when available - return setOf(getTokenFeeReceiverAddress()) + val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() } + .onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) } + .getOrNull() + return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress) } private companion object { val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000") + + + val KNOWN_FEE_COLLECTION_ADDRESSES = setOf( + "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c", + "0xAf722F46145fbb106379d506ED3a5B96f110c8E5", + ) } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index 2d6b86ba9a..8c566cd3d9 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import java.math.BigInteger @@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository( txHash = "0x000", ) + override suspend fun signGaslessBatchTransaction( + gaslessBatchTransactionData: GaslessBatchTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = GaslessSignedTransactionResult( + txHash = "0x000", + ) + override fun getBaseGasForTransaction(): BigInteger { return BASE_GAS_FOR_TRANSACTION } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/Eip7702AuthorizationConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/Eip7702AuthorizationConverter.kt new file mode 100644 index 0000000000..648c9ceab0 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/Eip7702AuthorizationConverter.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilder.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilder.kt new file mode 100644 index 0000000000..7e3a32cc28 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilder.kt @@ -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), + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt index b7caa4b13a..4f0417dbad 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt @@ -3,7 +3,6 @@ package com.tangem.data.transaction.convertes import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest import com.tangem.domain.transaction.models.Eip7702Authorization import com.tangem.domain.transaction.models.GaslessTransactionData -import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO /** * Builder for creating complete GaslessTransactionRequest from domain model. @@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO */ class GaslessTransactionRequestBuilder( private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(), + private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(), ) { /** @@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder( signature = signature, userAddress = userAddress, chainId = chainId, - eip7702Auth = eip7702Auth?.toDTO(), - ) - } - - /** - * Converts domain Eip7702Authorization to DTO. - */ - private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO { - return Eip7702AuthorizationDTO( - chainId = chainId, - address = address, - nonce = nonce.toString(), - yParity = yParity, - r = r, - s = s, + eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert), ) } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt index 925d10460a..502f87216c 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt @@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles * Note: This converter only handles the transaction data conversion. * Additional fields (signature, userAddress, chainId) must be added separately * to create complete GaslessTransactionRequest. + * + * @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the + * request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed. */ -class GaslessTxDataToGaslessRequestConverter : Converter { +class GaslessTxDataToGaslessRequestConverter( + private val shouldIncludeGasLimit: Boolean = true, +) : Converter { override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO { return GaslessTransactionDataDTO( @@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter> + 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" + } +} \ No newline at end of file diff --git a/data/transaction/src/test/kotlin/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilderTest.kt b/data/transaction/src/test/kotlin/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilderTest.kt new file mode 100644 index 0000000000..b1e1412951 --- /dev/null +++ b/data/transaction/src/test/kotlin/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilderTest.kt @@ -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") + } +} \ No newline at end of file diff --git a/data/yield-supply/build.gradle.kts b/data/yield-supply/build.gradle.kts index fc1e8aade5..b4a5685da1 100644 --- a/data/yield-supply/build.gradle.kts +++ b/data/yield-supply/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.core.analytics) /** Domain */ + implementation(projects.domain.transaction) implementation(projects.domain.yieldSupply) implementation(projects.domain.yieldSupply.models) implementation(projects.domain.walletManager) diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt index ac1c2517a6..7357341b16 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.* import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -238,6 +239,37 @@ internal class DefaultYieldSupplyTransactionRepository( YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData) } + override suspend fun getYieldModuleVersionStatus( + userWalletId: UserWalletId, + network: Network, + ): YieldModuleVersionStatus = withContext(dispatchers.io) { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = network.toBlockchain(), + derivationPath = network.derivationPath.value, + ) ?: error("Wallet manager not found for $network") + walletManager.checkModuleVersionStatus() + } + + override suspend fun createPartialWithdrawCallData( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + amount: Amount, + ): SmartContractCallData = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + val withdrawCallData = YieldSupplyContractCallDataProviderFactory.getWithdrawCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + amount = amount, + ) + val versionStatus = walletManager.checkModuleVersionStatus() + YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, withdrawCallData) + } + private suspend fun getYieldTokenStatus( walletManager: WalletManager, cryptoCurrency: CryptoCurrency.Token, diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index 38ff5bba43..faf4e7bce3 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -12,6 +12,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldModuleAddressProvider import com.tangem.domain.yield.supply.YieldSupplyRepository @@ -41,6 +42,14 @@ internal object YieldSupplyDataModule { ) } + @Provides + @Singleton + fun provideGaslessYieldRepository( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): GaslessYieldRepository { + return yieldSupplyTransactionRepository + } + @Provides @Singleton fun provideYieldSupplyMarketRepository( diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt index 310c568308..de155d8ad7 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -3,11 +3,14 @@ package com.tangem.data.yield.supply import com.google.common.truth.Truth import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.WalletManager import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -306,4 +309,35 @@ class DefaultYieldSupplyTransactionRepositoryTest { Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java) Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data) } + + @Test + fun `createPartialWithdrawCallData returns withdraw call data when module is up to date`() = runTest { + coEvery { walletManager.checkModuleVersionStatus() } returns YieldModuleVersionStatus.UpToDate + + val token = mockk(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") + } } \ No newline at end of file diff --git a/domain/legacy/src/main/assets/contract_methods.json b/domain/legacy/src/main/assets/contract_methods.json index 626e7657e1..fa8f6fe75c 100644 --- a/domain/legacy/src/main/assets/contract_methods.json +++ b/domain/legacy/src/main/assets/contract_methods.json @@ -251,5 +251,15 @@ "info": "GaslessTransactions", "source": "https://github.com/tangem-developments/tangem-gasless-service", "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" } } diff --git a/domain/legacy/src/test/java/com/tangem/domain/ContractMethodsAssetTest.kt b/domain/legacy/src/test/java/com/tangem/domain/ContractMethodsAssetTest.kt new file mode 100644 index 0000000000..af9319dca6 --- /dev/null +++ b/domain/legacy/src/test/java/com/tangem/domain/ContractMethodsAssetTest.kt @@ -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> 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>>(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") + } +} \ No newline at end of file diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 7d7aadb53f..02aa7efb9e 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.domain.transaction" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) @@ -42,6 +46,8 @@ dependencies { implementation(projects.domain.notifications) api(projects.domain.networks) + testRuntimeOnly(deps.test.junit5.engine) + testRuntimeOnly(deps.test.junit5.vintage.engine) testImplementation(projects.common.test) testImplementation(projects.test.core) testImplementation(projects.test.mock) diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt index db3123dee5..9d30d7e501 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt @@ -18,6 +18,7 @@ sealed class GetFeeError { data object NetworkIsNotSupported : GaslessError() data object NoSupportedTokensFound : GaslessError() data object NotEnoughFunds : GaslessError() + data object ModuleUpdateUnavailable : GaslessError() data class DataError(val cause: Throwable?) : GaslessError() } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 2f7b061892..09da65bb7c 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -3,6 +3,7 @@ package com.tangem.domain.transaction import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import java.math.BigInteger @@ -57,6 +58,33 @@ interface GaslessTransactionRepository { eip7702Auth: Eip7702Authorization? = null, ): 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 */ diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessYieldRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessYieldRepository.kt new file mode 100644 index 0000000000..bd7ce32ced --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessYieldRepository.kt @@ -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 +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessBatchTransactionData.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessBatchTransactionData.kt new file mode 100644 index 0000000000..444405f5ca --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessBatchTransactionData.kt @@ -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, + val fee: GaslessTransactionData.Fee, + val nonce: BigInteger, +) \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessFeePlan.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessFeePlan.kt new file mode 100644 index 0000000000..080c5789e2 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessFeePlan.kt @@ -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 +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt index c7ee49e692..0850791f86 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt @@ -15,16 +15,11 @@ data class GaslessTransactionData( val nonce: BigInteger, ) { - /** - * Core transaction data. - * - * @property to destination address - * @property value transaction value in wei (currently always 0 for gasless) - * @property data encoded transaction data (contract call) - */ + data class Transaction( val to: String, val value: BigInteger, + val gasLimit: BigInteger, val data: ByteArray, ) { override fun equals(other: Any?): Boolean { @@ -35,6 +30,7 @@ data class GaslessTransactionData( if (to != other.to) return false if (value != other.value) return false + if (gasLimit != other.gasLimit) return false if (!data.contentEquals(other.data)) return false return true @@ -43,6 +39,7 @@ data class GaslessTransactionData( override fun hashCode(): Int { var result = to.hashCode() result = 31 * result + value.hashCode() + result = 31 * result + gasLimit.hashCode() result = 31 * result + data.contentHashCode() return result } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt index 581292f419..31284d0b64 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt @@ -2,8 +2,28 @@ package com.tangem.domain.transaction.models import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigInteger data class TransactionFeeExtended( val transactionFee: TransactionFee, 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, ) \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index d307e498ab..cb448b6085 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -27,6 +27,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData +import com.tangem.domain.transaction.models.GaslessFeePlan import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.walletmanager.WalletManagersFacade @@ -38,6 +40,7 @@ class CreateAndSendGaslessTransactionUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, private val cardSdkConfigRepository: CardSdkConfigRepository, private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner, + private val isGaslessV2Enabled: Boolean, ) { suspend operator fun invoke( @@ -69,6 +72,12 @@ class CreateAndSendGaslessTransactionUseCase( /** * Prepares all necessary context for gasless transaction. * 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( userWallet: UserWallet, @@ -91,11 +100,17 @@ class CreateAndSendGaslessTransactionUseCase( val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress) - val gaslessTransactionData = createGaslessTransactionData( - transactionData = transactionData, - txFee = fee, - currency = currency, + val mainTxGasLimit = fee.mainTransactionGasLimit + ?: error("Main transaction gas limit is required for a gasless (token-fee) transaction") + val mainTx = buildTransaction(transactionData, mainTxGasLimit) + val feeObj = buildFee(fee, currency) + + val payload = assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, nonce = gaslessContractNonce, + plan = fee.gaslessFeePlan, + withdrawGasLimit = fee.withdrawGasLimit, ) val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network) @@ -104,7 +119,7 @@ class CreateAndSendGaslessTransactionUseCase( walletManager = walletManager, gaslessDataProvider = gaslessDataProvider, currency = currency, - gaslessTransactionData = gaslessTransactionData, + payload = payload, chainId = chainId, ) } @@ -125,17 +140,30 @@ class CreateAndSendGaslessTransactionUseCase( /** * Signs gasless transaction and EIP-7702 authorization. * 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( userWallet: UserWallet, context: GaslessContext, transactionData: TransactionData.Uncompiled, ): SignedGaslessData { - val eip712Data = Eip712TypedDataBuilder.build( - gaslessTransaction = context.gaslessTransactionData, - chainId = context.chainId, - verifyingContract = transactionData.sourceAddress, - ) + val eip712Data = when (val payload = context.payload) { + is GaslessPayload.Single -> Eip712TypedDataBuilder.build( + gaslessTransaction = payload.data, + 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 eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider) @@ -182,19 +210,34 @@ class CreateAndSendGaslessTransactionUseCase( /** * 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( context: GaslessContext, signedData: SignedGaslessData, transactionData: TransactionData.Uncompiled, ): String { - val txHash = gaslessTransactionRepository.signGaslessTransaction( - network = context.currency.network, - gaslessTransactionData = context.gaslessTransactionData, - signature = signedData.eip712Signature, - userAddress = transactionData.sourceAddress, - eip7702Auth = signedData.eip7702Auth, - ).txHash + val txHash = when (val payload = context.payload) { + is GaslessPayload.Single -> gaslessTransactionRepository.signGaslessTransaction( + network = context.currency.network, + gaslessTransactionData = payload.data, + signature = signedData.eip712Signature, + userAddress = transactionData.sourceAddress, + 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( transactionData = transactionData, @@ -241,23 +284,10 @@ class CreateAndSendGaslessTransactionUseCase( } } - private suspend fun createGaslessTransactionData( + private fun buildTransaction( transactionData: TransactionData.Uncompiled, - txFee: TransactionFeeExtended, - currency: CryptoCurrency, - 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 { + gasLimit: BigInteger, + ): GaslessTransactionData.Transaction { val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData ?: error("Ethereum call data is required") @@ -268,6 +298,7 @@ class CreateAndSendGaslessTransactionUseCase( return GaslessTransactionData.Transaction( to = getDestinationAddress(transactionData), value = nativeAmount, + gasLimit = gasLimit, data = callData.data, ) } @@ -295,20 +326,28 @@ class CreateAndSendGaslessTransactionUseCase( private suspend fun getEIP7702DataForGasless( gaslessDataProvider: EthereumGaslessDataProvider, ): 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.Success -> dataResult.data } } - private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { - val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData - val contractAddress = txData.contractAddress - return if (ethereumCallData is EthereumYieldSupplySendCallData) { - ethereumCallData.destinationAddress - } else { - contractAddress ?: error("supports only Token transaction with contract address") - } + /** + * Discriminated union of the gasless transaction payload to sign and send. + * + * [Single] carries a single-transaction payload (the pre-existing path). + * [Batch] carries a batch payload where the yield-withdraw call is appended as the second + * transaction so that staked tokens are unlocked before the fee is settled. + */ + 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 gaslessDataProvider: EthereumGaslessDataProvider, val currency: CryptoCurrency, - val gaslessTransactionData: GaslessTransactionData, + val payload: GaslessPayload, 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 { 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") + } + } } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt index 0c605613fe..471f1f980e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt @@ -1,6 +1,7 @@ package com.tangem.domain.transaction.usecase.gasless import com.tangem.common.extensions.toHexString +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData import org.json.JSONArray import org.json.JSONObject @@ -26,6 +27,7 @@ object Eip712TypedDataBuilder { private const val DOMAIN_NAME = "Tangem7702GaslessExecutor" private const val DOMAIN_VERSION = "1" private const val PRIMARY_TYPE = "GaslessTransaction" + private const val PRIMARY_TYPE_BATCH = "GaslessBatchTransaction" /** * Builds EIP-712 typed data JSON for gasless transaction. @@ -35,47 +37,106 @@ object Eip712TypedDataBuilder { * @param verifyingContract address of the deployed gasless executor contract * @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 { - put("types", buildTypes()) + put("types", buildTypes(includeGasLimit)) put("primaryType", PRIMARY_TYPE) put("domain", buildDomain(chainId, verifyingContract)) - put("message", buildMessage(gaslessTransaction)) + put("message", buildMessage(gaslessTransaction, includeGasLimit)) } 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, + includeGasLimit: Boolean, + ): JSONArray { + return JSONArray().apply { + transactions.forEach { tx -> put(buildTransactionMessage(tx, includeGasLimit)) } + } + } + /** * Builds the type definitions for all structures. * This schema is fixed and defines the structure of the data being signed. */ - @Suppress("NestedScopeFunctions") - private fun buildTypes(): JSONObject { + private fun buildTypes(includeGasLimit: Boolean): JSONObject { return JSONObject().apply { - put("EIP712Domain", JSONArray().apply { - put(typeProperty("name", "string")) - put(typeProperty("version", "string")) - put(typeProperty("chainId", "uint256")) - put(typeProperty("verifyingContract", "address")) - }) - put("Transaction", JSONArray().apply { - put(typeProperty("to", "address")) - put(typeProperty("value", "uint256")) - put(typeProperty("data", "bytes")) - }) - put("Fee", 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")) - }) - put("GaslessTransaction", JSONArray().apply { - put(typeProperty("transaction", "Transaction")) - put(typeProperty("fee", "Fee")) - put(typeProperty("nonce", "uint256")) - }) + put("EIP712Domain", buildEip712DomainTypeProperties()) + put("Transaction", buildTransactionTypeProperties(includeGasLimit)) + put("Fee", buildFeeTypeProperties()) + put("GaslessTransaction", buildGaslessTransactionTypeProperties()) + } + } + + private fun buildGaslessTransactionTypeProperties(): JSONArray { + return 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. */ - @Suppress("NestedScopeFunctions") - private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject { + private fun buildMessage(gaslessTransaction: GaslessTransactionData, includeGasLimit: Boolean): JSONObject { return JSONObject().apply { - put("transaction", JSONObject().apply { - put("to", gaslessTransaction.transaction.to) - 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("transaction", buildTransactionMessage(gaslessTransaction.transaction, includeGasLimit)) + put("fee", buildFeeMessage(gaslessTransaction.fee)) 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 } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt index 260a0dd6f1..7f11d3b31b 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -18,12 +18,14 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.repository.CurrencyChecksRepository 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.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.extensions.isZero import java.math.BigDecimal @Suppress("LongParameterList") @@ -31,6 +33,7 @@ class EstimateFeeForGaslessTxUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val estimateFeeUseCase: EstimateFeeUseCase, private val currencyChecksRepository: CurrencyChecksRepository, @@ -40,6 +43,7 @@ class EstimateFeeForGaslessTxUseCase( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -153,11 +157,11 @@ class EstimateFeeForGaslessTxUseCase( val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( network = nativeCurrencyStatus.currency.network, ).mapNotNull { currency -> - (currency as? CryptoCurrency.Token)?.contractAddress + (currency as? CryptoCurrency.Token)?.contractAddress?.lowercase() }.toSet() 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 } .filter { status -> val token = status.currency as? CryptoCurrency.Token ?: return@filter false diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt index c311cce1aa..c8d955b475 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.repository.CurrencyChecksRepository 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.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -22,18 +23,22 @@ import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal +@Suppress("LongParameterList") class EstimateFeeForTokenUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val currencyChecksRepository: CurrencyChecksRepository, + private val isYieldWithdrawEnabled: Boolean, ) { private val tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -70,11 +75,15 @@ class EstimateFeeForTokenUseCase( val walletManager = prepareWalletManager(userWallet, token.network) + val isYieldActive = isYieldWithdrawEnabled && + feeTokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true + tokenFeeCalculator.calculateTokenFee( walletManager = walletManager, tokenForPayFeeStatus = feeTokenCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus, initialFee = initialFeeEth, + isYieldActive = isYieldActive, ).bind() }, catch = { diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index ecac79ad59..2e8b9b51cb 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -19,6 +19,7 @@ class GetAvailableFeeTokensUseCase( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val gaslessTransactionRepository: GaslessTransactionRepository, private val currencyChecksRepository: CurrencyChecksRepository, + private val isYieldWithdrawEnabled: Boolean, ) { /** @@ -69,7 +70,7 @@ class GetAvailableFeeTokensUseCase( }.toSet() return userCurrenciesStatuses .asSequence() - .filter { it.value.yieldSupplyStatus == null } + .filter { isEligibleFeeToken(it, isYieldWithdrawEnabled) } .filter { it.currency.network.id == network.id } .filter { currencyStatus -> val token = currencyStatus.currency @@ -77,4 +78,12 @@ class GetAvailableFeeTokensUseCase( } .toList() } + + internal companion object { + + internal fun isEligibleFeeToken(status: CryptoCurrencyStatus, isYieldWithdrawEnabled: Boolean): Boolean { + val yieldSupplyStatus = status.value.yieldSupplyStatus ?: return true + return isYieldWithdrawEnabled && yieldSupplyStatus.isActive + } + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index c6bf7fc229..981230a637 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -6,6 +6,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee 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.tokens.repository.CurrencyChecksRepository 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.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -32,15 +34,19 @@ class GetFeeForGaslessUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeeUseCase: GetFeeUseCase, private val currencyChecksRepository: CurrencyChecksRepository, + private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + private val isYieldWithdrawEnabled: Boolean, ) { private val tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -80,11 +86,13 @@ class GetFeeForGaslessUseCase( ).bind() selectFeePaymentStrategy( + userWallet = userWallet, accountStatusList = accountStatusList, walletManager = walletManager, nativeCurrencyStatus = nativeCurrencyStatus, network = network, initialFee = initialFee, + transactionData = transactionData, ) }, catch = { @@ -108,12 +116,15 @@ class GetFeeForGaslessUseCase( return ethereumWalletManager } + @Suppress("LongParameterList") private suspend fun Raise.selectFeePaymentStrategy( + userWallet: UserWallet, accountStatusList: AccountStatusList, walletManager: EthereumWalletManager, nativeCurrencyStatus: CryptoCurrencyStatus, network: Network, initialFee: TransactionFee, + transactionData: TransactionData, ): TransactionFeeExtended { val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) @@ -128,10 +139,12 @@ class GetFeeForGaslessUseCase( nativeCoinSelectedResult } else { findTokensToPayFee( + userWallet = userWallet, walletManager = walletManager, initialTxFee = initialFee, nativeCurrencyStatus = nativeCurrencyStatus, networkCurrenciesStatuses = networkCurrenciesStatuses, + transactionData = transactionData, ).getOrElse { error -> when (error) { GaslessError.NotEnoughFunds -> nativeCoinSelectedResult @@ -141,12 +154,14 @@ class GetFeeForGaslessUseCase( } } - @Suppress("NullableToStringCall") + @Suppress("NullableToStringCall", "LongParameterList") private suspend fun findTokensToPayFee( + userWallet: UserWallet, walletManager: EthereumWalletManager, initialTxFee: TransactionFee, nativeCurrencyStatus: CryptoCurrencyStatus, networkCurrenciesStatuses: List, + transactionData: TransactionData, ): Either = either { val initialFee = initialTxFee.normal as? Fee.Ethereum ?: raiseIllegalStateError( @@ -156,29 +171,109 @@ class GetFeeForGaslessUseCase( val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( network = nativeCurrencyStatus.currency.network, ).mapNotNull { currency -> - (currency as? CryptoCurrency.Token)?.contractAddress + (currency as? CryptoCurrency.Token)?.contractAddress?.lowercase() }.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. - * Returns null if no suitable tokens found. + * Yield-aware candidate selection: + * 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() - ?: raise(GaslessError.NoSupportedTokensFound) + val candidates = networkCurrenciesStatuses + .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, tokenForPayFeeStatus = tokenForPayFeeStatus, nativeCurrencyStatus = nativeCurrencyStatus, 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.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.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 + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt index 1e96c27a19..60678ed6af 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -17,24 +17,30 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.repository.CurrencyChecksRepository 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.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.walletmanager.WalletManagersFacade +@Suppress("LongParameterList") class GetFeeForTokenUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val currencyChecksRepository: CurrencyChecksRepository, + private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + private val isYieldWithdrawEnabled: Boolean, ) { private val tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -74,12 +80,30 @@ class GetFeeForTokenUseCase( 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, tokenForPayFeeStatus = tokenCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus, initialFee = initialFeeEth, + isYieldActive = isYieldActive, + userWallet = userWallet, ).bind() + + if (isYieldActive) { + attachGaslessFeePlan( + resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase, + userWallet = userWallet, + tokenStatus = tokenCurrencyStatus, + tokenFeeExtended = tokenFeeExtended, + transactionData = transactionData, + isYieldActive = true, + ) + } else { + tokenFeeExtended + } }, catch = { raise(GaslessError.DataError(it)) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCase.kt new file mode 100644 index 0000000000..7409bbda93 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCase.kt @@ -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 = 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, + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index 451be7a94b..006ca05071 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -6,12 +6,15 @@ import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee 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.models.DemoConfig 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.wallet.UserWallet 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.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -34,6 +38,7 @@ internal class TokenFeeCalculator( private val walletManagersFacade: WalletManagersFacade, private val gaslessTransactionRepository: GaslessTransactionRepository, private val demoConfig: DemoConfig, + private val gaslessYieldRepository: GaslessYieldRepository, ) { suspend fun calculateInitialFee( @@ -90,16 +95,19 @@ internal class TokenFeeCalculator( } } - @Suppress("LongMethod", "CyclomaticComplexMethod") + @Suppress("LongMethod", "CyclomaticComplexity") suspend fun calculateTokenFee( walletManager: EthereumWalletManager, tokenForPayFeeStatus: CryptoCurrencyStatus, nativeCurrencyStatus: CryptoCurrencyStatus, initialFee: Fee.Ethereum, + isYieldActive: Boolean = false, + userWallet: UserWallet? = null, ): Either { return either { - // fast finish to skip calculations if no funds in token - if (tokenForPayFeeStatus.value.amount?.isZero() == true) { + // fast finish to skip calculations if no funds in token. + // 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) } @@ -120,23 +128,16 @@ internal class TokenFeeCalculator( ), ) - val feeTransferGasLimit = 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) { - raise(GaslessError.NotEnoughFunds) - } - } - raise(GaslessError.DataError(feeTransferGasLimitResult.error)) - } - }.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT) + val feeTransferGasLimit = resolveFeeTransferGasLimit(feeTransferGasLimitResult, isYieldActive) 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) { is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas @@ -170,7 +171,8 @@ internal class TokenFeeCalculator( ) 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) } @@ -186,10 +188,97 @@ internal class TokenFeeCalculator( TransactionFeeExtended( transactionFee = TransactionFee.Single(normal = fee), 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.resolveFeeTransferGasLimit( + feeTransferGasLimitResult: Result, + 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( token = Token( symbol = token.symbol, @@ -217,6 +306,26 @@ internal class TokenFeeCalculator( const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 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. * diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/models/GaslessBatchTransactionDataTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/models/GaslessBatchTransactionDataTest.kt new file mode 100644 index 0000000000..e3f05ba19f --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/models/GaslessBatchTransactionDataTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ComputeSendAmountInFeeTokenTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ComputeSendAmountInFeeTokenTest.kt new file mode 100644 index 0000000000..6bbb8a6b9c --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ComputeSendAmountInFeeTokenTest.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + computeSendAmountInFeeToken(compiled, feeContract) + } + + assertTrue(result.isLeft(), "Expected Left (error) for compiled tx") + assertTrue( + result.leftOrNull() is GetFeeError.DataError, + "Expected GetFeeError.DataError wrapping IllegalStateException", + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessDestinationAddressTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessDestinationAddressTest.kt new file mode 100644 index 0000000000..2ed5a47562 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessDestinationAddressTest.kt @@ -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 { + CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessPayloadTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessPayloadTest.kt new file mode 100644 index 0000000000..5f6767206d --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessPayloadTest.kt @@ -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 { + 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 { + CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, + nonce = nonce, + plan = plan, + withdrawGasLimit = null, + ) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderBatchTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderBatchTest.kt new file mode 100644 index 0000000000..01dba6099e --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderBatchTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderTest.kt new file mode 100644 index 0000000000..eeae86937b --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCaseTest.kt new file mode 100644 index 0000000000..966f906af4 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCaseTest.kt @@ -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() + 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) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCaseTest.kt new file mode 100644 index 0000000000..8ecbed7d23 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCaseTest.kt @@ -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().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(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(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(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(relaxed = true) + every { token.symbol } returns "USDC" + every { token.contractAddress } returns "0xUSDC" + every { token.decimals } returns decimals + + val status = mockk() + 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"), + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt index 8dffa62c8b..40fe32b035 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -14,7 +14,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet 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.GaslessYieldRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.walletmanager.WalletManagersFacade import io.mockk.coEvery @@ -36,6 +39,7 @@ class TokenFeeCalculatorTest { private lateinit var walletManagersFacade: WalletManagersFacade private lateinit var gaslessTransactionRepository: GaslessTransactionRepository + private lateinit var gaslessYieldRepository: GaslessYieldRepository private lateinit var demoConfig: DemoConfig private lateinit var tokenFeeCalculator: TokenFeeCalculator @@ -49,12 +53,14 @@ class TokenFeeCalculatorTest { fun setup() { walletManagersFacade = mockk() gaslessTransactionRepository = mockk() + gaslessYieldRepository = mockk() demoConfig = mockk() tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) mockWalletManager = mockk() @@ -215,6 +221,9 @@ class TokenFeeCalculatorTest { assertNotNull(feeExtended) assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId) 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(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 ===== private fun createMockTransactionFee(): TransactionFee { @@ -455,6 +741,21 @@ class TokenFeeCalculatorTest { 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() + 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( fiatRate: BigDecimal? = BigDecimal("2000"), decimals: Int = 18, @@ -471,4 +772,4 @@ class TokenFeeCalculatorTest { return status } -} +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt index cfb76be389..1cc70f6c46 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt @@ -3,13 +3,14 @@ package com.tangem.domain.yield.supply import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.smartcontract.SmartContractCallData 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.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.GaslessYieldRepository import java.math.BigDecimal -interface YieldSupplyTransactionRepository { +interface YieldSupplyTransactionRepository : GaslessYieldRepository { suspend fun createEnterTransactions( userWalletId: UserWalletId, @@ -23,10 +24,6 @@ interface YieldSupplyTransactionRepository { fee: Fee?, ): 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 * upgrade transaction if the deployed version is out of date. @@ -36,4 +33,7 @@ interface YieldSupplyTransactionRepository { network: Network, callData: SmartContractCallData, ): SmartContractCallData + + /** Returns the on-chain version status of the user's yield module for [network]. */ + suspend fun getYieldModuleVersionStatus(userWalletId: UserWalletId, network: Network): YieldModuleVersionStatus } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBlock.kt deleted file mode 100644 index eb95cd174f..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBlock.kt +++ /dev/null @@ -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, - 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 = {}, - ) - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt index 3f4557962e..d28fe50927 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt @@ -14,7 +14,13 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.expressStatus.ExpressEstimate import com.tangem.common.ui.expressStatus.ExpressHideButton 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.extensions.resourceReference import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH10 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.Companion.isFailed 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 kotlinx.collections.immutable.toImmutableList @Composable internal fun ExchangeStatusBottomSheetContent( @@ -80,11 +88,7 @@ internal fun ExchangeStatusBottomSheetContent( extraContent() SpacerH12() } - ExchangeStatusBlock( - statuses = state.statuses, - showLink = state.showProviderLink, - onClick = { state.info.onGoToProviderClick(state.info.txExternalUrl.orEmpty()) }, - ) + ExpressStatusBlock(state = state.toExpressStatusUM()) if (state.notification != null) { 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 private fun Notification(state: ExchangeStatusNotification, activeStatus: ExchangeStatus?) { AnimatedContent(