Updated on 2026-08-14
This commit is contained in:
parent
7e94a484a0
commit
80e33b892a
51 changed files with 2975 additions and 445 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.datasource.api.gasless
|
||||
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
|
||||
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
|
||||
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO
|
||||
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface GaslessTxServiceApiV2 {
|
||||
@POST("api/v2/transaction/sign")
|
||||
suspend fun signGaslessTransaction(
|
||||
@Body transaction: GaslessTransactionRequest,
|
||||
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
|
||||
|
||||
@POST("api/v2/transaction/batch-sign")
|
||||
suspend fun signGaslessBatchTransaction(
|
||||
@Body transaction: GaslessBatchTransactionRequest,
|
||||
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.datasource.api.gasless.models
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* Request body for gasless batch transaction submission (v2 `POST /api/v2/transaction/batch-sign`).
|
||||
* Represents a batch of transactions with fee delegation metadata.
|
||||
*
|
||||
* The top-level payload field is `gaslessTransaction` (shared shape with single sign — see
|
||||
* gasless-service `BatchSignRequestDto`), carrying `transactions[]`, `fee`, `nonce`.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GaslessBatchTransactionRequest(
|
||||
@Json(name = "gaslessTransaction")
|
||||
val gaslessTransaction: GaslessBatchTransactionDataDTO,
|
||||
|
||||
@Json(name = "signature")
|
||||
val signature: String,
|
||||
|
||||
@Json(name = "userAddress")
|
||||
val userAddress: String,
|
||||
|
||||
@Json(name = "chainId")
|
||||
val chainId: Int,
|
||||
|
||||
@Json(name = "eip7702auth")
|
||||
val eip7702Auth: Eip7702AuthorizationDTO? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GaslessBatchTransactionDataDTO(
|
||||
@Json(name = "transactions")
|
||||
val transactions: List<TransactionData>,
|
||||
|
||||
@Json(name = "fee")
|
||||
val fee: FeeData,
|
||||
|
||||
@Json(name = "nonce")
|
||||
val nonce: String,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ dependencies {
|
|||
implementation(tangemDeps.card.core)
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,23 @@ package com.tangem.data.transaction
|
|||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
|
||||
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -23,17 +29,19 @@ import java.math.BigInteger
|
|||
|
||||
class DefaultGaslessTransactionRepository(
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
private val isGaslessV2Enabled: Boolean,
|
||||
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : GaslessTransactionRepository {
|
||||
|
||||
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
|
||||
private val allFeeRecipientAddress = mutableSetOf<String>()
|
||||
private val allAddressesMutex = Mutex()
|
||||
private val receiverAddressMutex = Mutex()
|
||||
private var feeReceiverAddress: String? = null
|
||||
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
|
||||
private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled)
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter)
|
||||
private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter)
|
||||
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
|
||||
|
||||
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
||||
|
|
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
|
|||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
|
||||
val response = if (isGaslessV2Enabled) {
|
||||
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
|
||||
} else {
|
||||
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
|
||||
}.getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
|
|
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
|
|||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = network.toBlockchain()
|
||||
val transactionRequest = gaslessBatchTransactionRequestBuilder.build(
|
||||
gaslessBatchTransaction = gaslessBatchTransactionData,
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
|
||||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
}
|
||||
|
||||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
|
|||
}
|
||||
|
||||
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
||||
return allAddressesMutex.withLock {
|
||||
allFeeRecipientAddress.ifEmpty {
|
||||
val allFeeAddresses = getAllFeeRecipientAddresses()
|
||||
allFeeRecipientAddress.addAll(allFeeAddresses)
|
||||
allFeeRecipientAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
|
||||
// TODO Replace with other backend call to get all fee recipient addresses when available
|
||||
return setOf(getTokenFeeReceiverAddress())
|
||||
val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() }
|
||||
.onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) }
|
||||
.getOrNull()
|
||||
return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
|
||||
|
||||
|
||||
val KNOWN_FEE_COLLECTION_ADDRESSES = setOf(
|
||||
"0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c",
|
||||
"0xAf722F46145fbb106379d506ED3a5B96f110c8E5",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import java.math.BigInteger
|
||||
|
|
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
|
|||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
|
||||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converts domain [Eip7702Authorization] to its DTO representation.
|
||||
* Shared by both single-transaction and batch-transaction request builders.
|
||||
*/
|
||||
class Eip7702AuthorizationConverter : Converter<Eip7702Authorization, Eip7702AuthorizationDTO> {
|
||||
|
||||
override fun convert(value: Eip7702Authorization): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = value.chainId,
|
||||
address = value.address,
|
||||
nonce = value.nonce.toString(),
|
||||
yParity = value.yParity,
|
||||
r = value.r,
|
||||
s = value.s,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionDataDTO
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
|
||||
/**
|
||||
* Builder for creating complete [GaslessBatchTransactionRequest] from domain model.
|
||||
* Combines batch transaction data with signature and user information.
|
||||
*
|
||||
* Reuses [GaslessTxDataToGaslessRequestConverter] for transaction and fee conversion
|
||||
* to avoid duplicating mapping logic.
|
||||
*/
|
||||
class GaslessBatchTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Creates complete gasless batch transaction request.
|
||||
*
|
||||
* @param gaslessBatchTransaction domain model of batch transaction
|
||||
* @param signature transaction signature in hex format (with 0x prefix)
|
||||
* @param userAddress user's Ethereum address
|
||||
* @param chainId blockchain network chain ID
|
||||
* @param eip7702Auth optional EIP-7702 authorization for account abstraction
|
||||
* @return complete request ready for API submission
|
||||
*/
|
||||
fun build(
|
||||
gaslessBatchTransaction: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
chainId: Int,
|
||||
eip7702Auth: Eip7702Authorization? = null,
|
||||
): GaslessBatchTransactionRequest {
|
||||
return GaslessBatchTransactionRequest(
|
||||
gaslessTransaction = GaslessBatchTransactionDataDTO(
|
||||
transactions = gaslessBatchTransaction.transactions.map { converter.convertTransaction(it) },
|
||||
fee = converter.convertFee(gaslessBatchTransaction.fee),
|
||||
nonce = gaslessBatchTransaction.nonce.toString(),
|
||||
),
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.tangem.data.transaction.convertes
|
|||
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
|
||||
/**
|
||||
* Builder for creating complete GaslessTransactionRequest from domain model.
|
||||
|
|
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
|||
*/
|
||||
class GaslessTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
|
|||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.toDTO(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domain Eip7702Authorization to DTO.
|
||||
*/
|
||||
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = chainId,
|
||||
address = address,
|
||||
nonce = nonce.toString(),
|
||||
yParity = yParity,
|
||||
r = r,
|
||||
s = s,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
|
|||
* Note: This converter only handles the transaction data conversion.
|
||||
* Additional fields (signature, userAddress, chainId) must be added separately
|
||||
* to create complete GaslessTransactionRequest.
|
||||
*
|
||||
* @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the
|
||||
* request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed.
|
||||
*/
|
||||
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
class GaslessTxDataToGaslessRequestConverter(
|
||||
private val shouldIncludeGasLimit: Boolean = true,
|
||||
) : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
|
||||
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
|
||||
return GaslessTransactionDataDTO(
|
||||
|
|
@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
|
|||
)
|
||||
}
|
||||
|
||||
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
internal fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
return TransactionData(
|
||||
to = transaction.to,
|
||||
value = transaction.value.toString(),
|
||||
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
|
||||
data = transaction.data.toHexString().formatHex(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
return FeeData(
|
||||
feeToken = fee.feeToken,
|
||||
maxTokenFee = fee.maxTokenFee.toString(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
|||
import com.tangem.data.transaction.*
|
||||
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
|
||||
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
|
|
@ -84,10 +87,17 @@ internal object TransactionDataModule {
|
|||
fun provideGaslessTransactionRepository(
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
): GaslessTransactionRepository {
|
||||
return DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
// Single master toggle for the whole gasless v2 protocol (+ yield-withdraw batch).
|
||||
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||
),
|
||||
coroutineDispatcherProvider = coroutineDispatcherProvider,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
package com.tangem.data.transaction
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.gasless.models.GaslessFeeRecipient
|
||||
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class DefaultGaslessTransactionRepositoryTest {
|
||||
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi = mockk()
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2 = mockk()
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(gaslessTxServiceApi, gaslessTxServiceApiV2, responseCryptoCurrenciesFactory)
|
||||
}
|
||||
|
||||
private fun createRepository() = DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
isGaslessV2Enabled = true,
|
||||
coroutineDispatcherProvider = TestingCoroutineDispatcherProvider(),
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
||||
private fun stubFeeRecipientSuccess(address: String) {
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns ApiResponse.Success(
|
||||
data = GaslessServiceResponse(
|
||||
result = GaslessFeeRecipient(address = address),
|
||||
isSuccess = true,
|
||||
timestamp = "2026-06-11T00:00:00.000Z",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun stubFeeRecipientFailure() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val error = ApiResponse.Error(cause = ApiResponseError.NetworkException())
|
||||
as ApiResponse<GaslessServiceResponse<GaslessFeeRecipient>>
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns error
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend returns recipient WHEN getGaslessFeeAddresses THEN hardcoded plus backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails WHEN getGaslessFeeAddresses THEN hardcoded addresses only`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails then recovers WHEN called twice THEN second call includes backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
val firstResult = repository.getGaslessFeeAddresses()
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
|
||||
// Act
|
||||
val secondResult = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(firstResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
assertThat(secondResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend succeeds WHEN called twice THEN fee recipient requested once`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
repository.getGaslessFeeAddresses()
|
||||
repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { gaslessTxServiceApi.getFeeRecipient() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HARDCODED_ADDRESS_1 = "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c"
|
||||
const val HARDCODED_ADDRESS_2 = "0xAf722F46145fbb106379d506ED3a5B96f110c8E5"
|
||||
const val BACKEND_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigInteger
|
||||
|
||||
class GaslessBatchTransactionRequestBuilderTest {
|
||||
|
||||
private val builder = GaslessBatchTransactionRequestBuilder()
|
||||
|
||||
// byteArrayOf(0x12, 0x34).toHexString() == "1234" (uppercase), .formatHex() prepends "0x" → "0x1234"
|
||||
private val tx1Data = byteArrayOf(0x12, 0x34)
|
||||
private val tx1DataHex = "0x1234"
|
||||
|
||||
// byteArrayOf(0xAB.toByte(), 0xCD.toByte()).toHexString() == "ABCD", .formatHex() → "0xABCD"
|
||||
private val tx2Data = byteArrayOf(0xAB.toByte(), 0xCD.toByte())
|
||||
private val tx2DataHex = "0xABCD"
|
||||
|
||||
private val tx1 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractA",
|
||||
value = BigInteger("100"),
|
||||
gasLimit = BigInteger("120000"),
|
||||
data = tx1Data,
|
||||
)
|
||||
private val tx2 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractB",
|
||||
value = BigInteger("0"),
|
||||
gasLimit = BigInteger("150000"),
|
||||
data = tx2Data,
|
||||
)
|
||||
private val fee = GaslessTransactionData.Fee(
|
||||
feeToken = "0xFeeToken",
|
||||
maxTokenFee = BigInteger("500"),
|
||||
coinPriceInToken = BigInteger("200"),
|
||||
feeTransferGasLimit = BigInteger("21000"),
|
||||
baseGas = BigInteger("60000"),
|
||||
feeReceiver = "0xFeeReceiver",
|
||||
)
|
||||
private val nonce = BigInteger("42")
|
||||
|
||||
private val batchData = GaslessBatchTransactionData(
|
||||
transactions = listOf(tx1, tx2),
|
||||
fee = fee,
|
||||
nonce = nonce,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `build - transactions list has correct size and order`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.transactions).hasSize(2)
|
||||
assertThat(result.gaslessTransaction.transactions[0].to).isEqualTo("0xContractA")
|
||||
assertThat(result.gaslessTransaction.transactions[1].to).isEqualTo("0xContractB")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - transaction data fields are encoded correctly`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
// data bytes are hex-encoded with 0x prefix (uppercase)
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
assertThat(txDtoList[1].data).isEqualTo(tx2DataHex)
|
||||
// value is BigInteger.toString()
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[1].value).isEqualTo("0")
|
||||
// v2: per-call gasLimit is BigInteger.toString()
|
||||
assertThat(txDtoList[0].gasLimit).isEqualTo("120000")
|
||||
assertThat(txDtoList[1].gasLimit).isEqualTo("150000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - v1 converter omits per-call gasLimit`() {
|
||||
// Arrange: a builder whose converter is in v1 mode (shouldIncludeGasLimit = false)
|
||||
val v1Builder = GaslessBatchTransactionRequestBuilder(
|
||||
converter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = false),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = v1Builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
// Assert: gasLimit is null so Moshi omits it, restoring the legacy v1 {to, value, data} shape
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
assertThat(txDtoList[0].gasLimit).isNull()
|
||||
assertThat(txDtoList[1].gasLimit).isNull()
|
||||
// other fields are unaffected
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - fee fields are all toString of BigInteger inputs`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val feeDto = result.gaslessTransaction.fee
|
||||
assertThat(feeDto.feeToken).isEqualTo("0xFeeToken")
|
||||
assertThat(feeDto.maxTokenFee).isEqualTo("500")
|
||||
assertThat(feeDto.coinPriceInToken).isEqualTo("200")
|
||||
assertThat(feeDto.feeTransferGasLimit).isEqualTo("21000")
|
||||
assertThat(feeDto.baseGas).isEqualTo("60000")
|
||||
assertThat(feeDto.feeReceiver).isEqualTo("0xFeeReceiver")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - nonce is toString of BigInteger input`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.nonce).isEqualTo("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - top-level signature, userAddress, chainId pass through`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xDeadBeef",
|
||||
userAddress = "0xAlice",
|
||||
chainId = 137,
|
||||
)
|
||||
|
||||
assertThat(result.signature).isEqualTo("0xDeadBeef")
|
||||
assertThat(result.userAddress).isEqualTo("0xAlice")
|
||||
assertThat(result.chainId).isEqualTo(137)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth is null when not provided`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.eip7702Auth).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth maps correctly when provided`() {
|
||||
val auth = Eip7702Authorization(
|
||||
chainId = 1,
|
||||
address = "0xEntryPoint",
|
||||
nonce = BigInteger("7"),
|
||||
yParity = 0,
|
||||
r = "0xRValue",
|
||||
s = "0xSValue",
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
eip7702Auth = auth,
|
||||
)
|
||||
|
||||
val authDto = result.eip7702Auth
|
||||
assertThat(authDto).isNotNull()
|
||||
assertThat(authDto!!.chainId).isEqualTo(1)
|
||||
assertThat(authDto.address).isEqualTo("0xEntryPoint")
|
||||
assertThat(authDto.nonce).isEqualTo("7")
|
||||
assertThat(authDto.yParity).isEqualTo(0)
|
||||
assertThat(authDto.r).isEqualTo("0xRValue")
|
||||
assertThat(authDto.s).isEqualTo("0xSValue")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ package com.tangem.data.yield.supply
|
|||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.AmountType
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.WalletManager
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
|
||||
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -306,4 +309,35 @@ class DefaultYieldSupplyTransactionRepositoryTest {
|
|||
Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java)
|
||||
Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createPartialWithdrawCallData returns withdraw call data when module is up to date`() = runTest {
|
||||
coEvery { walletManager.checkModuleVersionStatus() } returns YieldModuleVersionStatus.UpToDate
|
||||
|
||||
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { contractAddress } returns mockedContractAddress
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val amount = Amount(
|
||||
currencySymbol = "USDC",
|
||||
value = BigDecimal("1.5"),
|
||||
decimals = 6,
|
||||
type = AmountType.Token(
|
||||
token = Token(
|
||||
symbol = "USDC",
|
||||
contractAddress = mockedContractAddress,
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = repository.createPartialWithdrawCallData(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrency = token,
|
||||
amount = amount,
|
||||
)
|
||||
|
||||
Truth.assertThat(result).isNotNull()
|
||||
Truth.assertThat(result.methodId).isEqualTo("0xf3fef3a3")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Guards the `contract_methods.json` asset consumed by `SdkTransactionTypeConverter` (via
|
||||
* `DefaultWalletManagersFacade.readSmartContractMethods`). History marking of gasless fee transfers
|
||||
* relies on every gasless entry-point selector being mapped to the `gaslessTransaction` method name.
|
||||
*/
|
||||
internal class ContractMethodsAssetTest {
|
||||
|
||||
private val methods: Map<String, Map<String, String>> by lazy {
|
||||
val json = File("src/main/assets/contract_methods.json").readText()
|
||||
val type = Types.newParameterizedType(
|
||||
Map::class.java,
|
||||
String::class.java,
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java),
|
||||
)
|
||||
requireNotNull(Moshi.Builder().build().adapter<Map<String, Map<String, String>>>(type).fromJson(json))
|
||||
}
|
||||
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["0x6234d42b", "0x4b072692", "0xf9b181bf"])
|
||||
fun `GIVEN gasless selector WHEN asset parsed THEN maps to gaslessTransaction`(selector: String) {
|
||||
val entry = methods[selector]
|
||||
|
||||
assertThat(entry).isNotNull()
|
||||
assertThat(entry?.get("name")).isEqualTo("gaslessTransaction")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@ android {
|
|||
namespace = "com.tangem.domain.transaction"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.domain.transaction.models
|
||||
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Domain model for a gasless BATCH transaction (EIP-712 primaryType `GaslessBatchTransaction`).
|
||||
* Reuses [GaslessTransactionData.Transaction] and [GaslessTransactionData.Fee].
|
||||
*
|
||||
* @property transactions ordered list — index 0 is the user's main transaction, subsequent entries
|
||||
* are appended operations (e.g. the yield `withdraw`). Executed in array order.
|
||||
* @property fee fee payment configuration.
|
||||
* @property nonce nonce from the user's contract.
|
||||
*/
|
||||
data class GaslessBatchTransactionData(
|
||||
val transactions: List<GaslessTransactionData.Transaction>,
|
||||
val fee: GaslessTransactionData.Fee,
|
||||
val nonce: BigInteger,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GaslessTransactionData.Transaction>,
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GetFeeError>.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<CryptoCurrencyStatus>,
|
||||
transactionData: TransactionData,
|
||||
): Either<GetFeeError, TransactionFeeExtended> = 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<GetFeeError>.attachGaslessFeePlan(
|
||||
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
|
||||
userWallet: UserWallet,
|
||||
tokenStatus: CryptoCurrencyStatus,
|
||||
tokenFeeExtended: TransactionFeeExtended,
|
||||
transactionData: TransactionData,
|
||||
isYieldActive: Boolean,
|
||||
): TransactionFeeExtended {
|
||||
val feeInTokenCurrency = tokenFeeExtended.transactionFee.normal as? Fee.Ethereum.TokenCurrency
|
||||
?: raiseIllegalStateError("gasless token fee must be Fee.Ethereum.TokenCurrency")
|
||||
val feeTokenContract = (tokenStatus.currency as? CryptoCurrency.Token)?.contractAddress
|
||||
?: raiseIllegalStateError("gasless fee currency must be a token")
|
||||
|
||||
val plan = resolveGaslessFeePlanUseCase(
|
||||
userWallet = userWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = feeInTokenCurrency,
|
||||
isYieldActive = isYieldActive,
|
||||
sendAmountInFeeToken = computeSendAmountInFeeToken(transactionData, feeTokenContract),
|
||||
).bind()
|
||||
|
||||
return tokenFeeExtended.copy(gaslessFeePlan = plan)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes how much of the fee token is also being spent in the main transaction body.
|
||||
*
|
||||
* Gasless token-fee transactions MUST supply uncompiled data (the resolver needs the raw amount to
|
||||
* account for it in the required-balance check). A compiled tx or a null sent amount on the
|
||||
* matching-token path are both programmer errors, so they raise loudly instead of silently
|
||||
* under-accounting as ZERO.
|
||||
*
|
||||
* @param transactionData the raw transaction data passed into [GetFeeForGaslessUseCase].
|
||||
* @param feeTokenContract the contract address of the token selected to pay the gasless fee.
|
||||
* @return the sent amount when [feeTokenContract] matches the sent-token contract,
|
||||
* or [BigDecimal.ZERO] when a different token is being sent.
|
||||
*/
|
||||
internal fun Raise<GetFeeError>.computeSendAmountInFeeToken(
|
||||
transactionData: TransactionData,
|
||||
feeTokenContract: String,
|
||||
): BigDecimal {
|
||||
// Gasless token-fee requires uncompiled tx data (mirrors CreateAndSendGaslessTransactionUseCase).
|
||||
val uncompiled = transactionData as? TransactionData.Uncompiled
|
||||
?: raiseIllegalStateError("gasless token fee requires uncompiled transaction data")
|
||||
val sentTokenContract = when (val type = uncompiled.amount.type) {
|
||||
is AmountType.Token -> type.token.contractAddress
|
||||
is AmountType.TokenYieldSupply -> type.token.contractAddress
|
||||
else -> null
|
||||
}
|
||||
return if (sentTokenContract != null && sentTokenContract.equals(feeTokenContract, ignoreCase = true)) {
|
||||
uncompiled.amount.value
|
||||
?: raiseIllegalStateError("sent amount is null while paying the gasless fee in the sent token")
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||
import com.tangem.domain.transaction.models.GaslessFeePlan
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
class ResolveGaslessFeePlanUseCase(
|
||||
private val gaslessYieldRepository: GaslessYieldRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
tokenStatus: CryptoCurrencyStatus,
|
||||
tokenFee: Fee.Ethereum.TokenCurrency,
|
||||
isYieldActive: Boolean,
|
||||
sendAmountInFeeToken: BigDecimal,
|
||||
): Either<GetFeeError, GaslessFeePlan> = either {
|
||||
val token = tokenStatus.currency as? CryptoCurrency.Token
|
||||
?: raise(GaslessError.DataError(IllegalStateException("fee currency must be a token")))
|
||||
|
||||
val feeAmount = tokenFee.amount.value
|
||||
?: raise(GaslessError.DataError(IllegalStateException("token fee amount is null")))
|
||||
val totalBalance = tokenStatus.value.amount ?: BigDecimal.ZERO
|
||||
val required = feeAmount + sendAmountInFeeToken
|
||||
if (!isYieldActive) {
|
||||
return@either if (totalBalance >= required) {
|
||||
GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
|
||||
} else {
|
||||
raise(GaslessError.NotEnoughFunds)
|
||||
}
|
||||
}
|
||||
|
||||
val moduleBalance = gaslessYieldRepository
|
||||
.getEffectiveProtocolBalance(userWallet.walletId, token) ?: BigDecimal.ZERO
|
||||
|
||||
// Liquid balance already on the EOA = total - what is held inside the yield module.
|
||||
val liquidBalance = (totalBalance - moduleBalance).coerceAtLeast(BigDecimal.ZERO)
|
||||
if (liquidBalance >= required) {
|
||||
return@either GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
|
||||
}
|
||||
|
||||
if (totalBalance < required) raise(GaslessError.NotEnoughFunds)
|
||||
|
||||
val liquidLeftForFee = (liquidBalance - sendAmountInFeeToken).coerceAtLeast(BigDecimal.ZERO)
|
||||
val withdrawAmountDecimal = (feeAmount - liquidLeftForFee).coerceAtLeast(BigDecimal.ZERO)
|
||||
|
||||
val withdrawCallData = catch(
|
||||
block = {
|
||||
gaslessYieldRepository.createPartialWithdrawCallData(
|
||||
userWalletId = userWallet.walletId,
|
||||
cryptoCurrency = token,
|
||||
amount = Amount(
|
||||
token = Token(token.symbol, token.contractAddress, token.decimals),
|
||||
value = withdrawAmountDecimal,
|
||||
),
|
||||
)
|
||||
},
|
||||
catch = { error ->
|
||||
when (error) {
|
||||
is YieldModuleUpgradeUnavailableException,
|
||||
is YieldModuleVersionIndeterminateException,
|
||||
-> raise(GaslessError.ModuleUpdateUnavailable)
|
||||
else -> raise(GaslessError.DataError(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val yieldModuleAddress = gaslessYieldRepository
|
||||
.getYieldContractAddress(userWallet.walletId, token)
|
||||
?: raise(GaslessError.DataError(IllegalStateException("yield module address is null")))
|
||||
|
||||
GaslessFeePlan.TokenPayWithYieldWithdraw(
|
||||
feeToken = token,
|
||||
fee = tokenFee,
|
||||
withdrawAmount = withdrawAmountDecimal
|
||||
.movePointRight(token.decimals)
|
||||
.setScale(0, RoundingMode.CEILING)
|
||||
.toBigInteger(),
|
||||
withdrawCallData = withdrawCallData,
|
||||
yieldModuleAddress = yieldModuleAddress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GetFeeError, TransactionFeeExtended> {
|
||||
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<GetFeeError>.resolveFeeTransferGasLimit(
|
||||
feeTransferGasLimitResult: Result<BigInteger>,
|
||||
isYieldActive: Boolean,
|
||||
): BigInteger {
|
||||
val rawFeeTransferGasLimit: BigInteger = when (feeTransferGasLimitResult) {
|
||||
is Result.Success -> feeTransferGasLimitResult.data
|
||||
is Result.Failure -> {
|
||||
// If there is a dust on the balance, the gas limit estimation will fail with code
|
||||
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
|
||||
val cause = feeTransferGasLimitResult.error.cause
|
||||
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
|
||||
if (isYieldActive) {
|
||||
FALLBACK_FEE_TRANSFER_GAS_LIMIT
|
||||
} else {
|
||||
raise(GaslessError.NotEnoughFunds)
|
||||
}
|
||||
} else {
|
||||
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
|
||||
}
|
||||
} else {
|
||||
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
return rawFeeTransferGasLimit.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
|
||||
}
|
||||
|
||||
@Suppress("SwallowedException")
|
||||
private suspend fun estimateWithdrawGasLimit(
|
||||
userWallet: UserWallet?,
|
||||
walletManager: EthereumWalletManager,
|
||||
token: CryptoCurrency.Token,
|
||||
): BigInteger {
|
||||
if (userWallet == null) return WITHDRAW_GAS_LIMIT
|
||||
|
||||
val moduleAddress = gaslessYieldRepository.getYieldContractAddress(userWallet.walletId, token)
|
||||
?: return WITHDRAW_GAS_LIMIT
|
||||
|
||||
// The withdraw amount is encoded into the call data: a small fixed probe whose exact value does not
|
||||
// affect the gas cost. It is a token amount because the call data needs the token's contract/decimals.
|
||||
val withdrawAmount = createTokenAmount(
|
||||
token = token,
|
||||
value = BigDecimal(PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS).movePointLeft(token.decimals),
|
||||
)
|
||||
|
||||
val probeCallData = try {
|
||||
gaslessYieldRepository.createPartialWithdrawCallData(
|
||||
userWalletId = userWallet.walletId,
|
||||
cryptoCurrency = token,
|
||||
amount = withdrawAmount,
|
||||
)
|
||||
} catch (e: YieldModuleUpgradeUnavailableException) {
|
||||
return WITHDRAW_GAS_LIMIT
|
||||
} catch (e: YieldModuleVersionIndeterminateException) {
|
||||
return WITHDRAW_GAS_LIMIT
|
||||
}
|
||||
|
||||
// Mirrors the real batch sub-call (see CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload):
|
||||
// `to = moduleAddress`, zero native value, withdraw call data. A zero-value Coin amount is required so
|
||||
// that EthereumWalletManager.getGasLimit keeps `to` = moduleAddress — a Token amount would override it
|
||||
// with the token contract address and estimate the wrong call.
|
||||
val estimationAmount = Amount(
|
||||
currencySymbol = token.symbol,
|
||||
value = BigDecimal.ZERO,
|
||||
decimals = token.decimals,
|
||||
type = AmountType.Coin,
|
||||
)
|
||||
|
||||
return when (val result = walletManager.getGasLimit(estimationAmount, moduleAddress, probeCallData)) {
|
||||
is Result.Success -> result.data
|
||||
is Result.Failure -> WITHDRAW_GAS_LIMIT
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
|
||||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.AmountType
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Unit tests for [computeSendAmountInFeeToken].
|
||||
*
|
||||
* Cases:
|
||||
* (a) Different token → ZERO (fee token ≠ sent token).
|
||||
* (b) Same token via AmountType.Token → the actual sent amount.
|
||||
* (c) Same token via AmountType.TokenYieldSupply → the actual sent amount.
|
||||
* (d) Same token but amount.value == null → raises (loud error, never silent ZERO).
|
||||
* (e) Compiled tx → raises (gasless token-fee requires uncompiled data).
|
||||
*/
|
||||
class ComputeSendAmountInFeeTokenTest {
|
||||
|
||||
private val feeContract = "0xUSDC"
|
||||
private val otherContract = "0xDAI"
|
||||
private val sentAmount = BigDecimal("50.0")
|
||||
|
||||
private fun makeToken(contract: String) = Token(
|
||||
name = "TestToken",
|
||||
symbol = "TST",
|
||||
contractAddress = contract,
|
||||
decimals = 6,
|
||||
)
|
||||
|
||||
private fun uncompiledWith(type: AmountType, value: BigDecimal?) = TransactionData.Uncompiled(
|
||||
amount = Amount(
|
||||
currencySymbol = "TST",
|
||||
value = value,
|
||||
maxValue = null,
|
||||
decimals = 6,
|
||||
type = type,
|
||||
),
|
||||
sourceAddress = "0xSrc",
|
||||
destinationAddress = "0xDst",
|
||||
fee = null,
|
||||
)
|
||||
|
||||
// (a) Sent token is different from fee token → ZERO
|
||||
@Test
|
||||
fun `returns ZERO when sent token differs from fee token`() {
|
||||
val tx = uncompiledWith(
|
||||
type = AmountType.Token(makeToken(otherContract)),
|
||||
value = sentAmount,
|
||||
)
|
||||
|
||||
val result = either<GetFeeError, BigDecimal> {
|
||||
computeSendAmountInFeeToken(tx, feeContract)
|
||||
}
|
||||
|
||||
assertTrue(result.isRight())
|
||||
assertEquals(BigDecimal.ZERO, result.getOrNull())
|
||||
}
|
||||
|
||||
// (b) AmountType.Token — same contract as fee token → returns the sent amount
|
||||
@Test
|
||||
fun `returns sent amount when AmountType Token matches fee token contract`() {
|
||||
val tx = uncompiledWith(
|
||||
type = AmountType.Token(makeToken(feeContract)),
|
||||
value = sentAmount,
|
||||
)
|
||||
|
||||
val result = either<GetFeeError, BigDecimal> {
|
||||
computeSendAmountInFeeToken(tx, feeContract)
|
||||
}
|
||||
|
||||
assertTrue(result.isRight())
|
||||
assertEquals(sentAmount, result.getOrNull())
|
||||
}
|
||||
|
||||
// (b) Case-insensitive contract address match
|
||||
@Test
|
||||
fun `contract address comparison is case-insensitive`() {
|
||||
val tx = uncompiledWith(
|
||||
type = AmountType.Token(makeToken(feeContract.uppercase())),
|
||||
value = sentAmount,
|
||||
)
|
||||
|
||||
val result = either<GetFeeError, BigDecimal> {
|
||||
computeSendAmountInFeeToken(tx, feeContract.lowercase())
|
||||
}
|
||||
|
||||
assertTrue(result.isRight())
|
||||
assertEquals(sentAmount, result.getOrNull())
|
||||
}
|
||||
|
||||
// (c) AmountType.TokenYieldSupply — same contract as fee token → returns the sent amount
|
||||
@Test
|
||||
fun `returns sent amount when AmountType TokenYieldSupply matches fee token contract`() {
|
||||
val tx = uncompiledWith(
|
||||
type = AmountType.TokenYieldSupply(
|
||||
token = makeToken(feeContract),
|
||||
isActive = true,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = true,
|
||||
),
|
||||
value = sentAmount,
|
||||
)
|
||||
|
||||
val result = either<GetFeeError, BigDecimal> {
|
||||
computeSendAmountInFeeToken(tx, feeContract)
|
||||
}
|
||||
|
||||
assertTrue(result.isRight())
|
||||
assertEquals(sentAmount, result.getOrNull())
|
||||
}
|
||||
|
||||
// (d) Same token but amount.value == null → raises (never silently under-accounts as ZERO)
|
||||
@Test
|
||||
fun `raises when same token is sent but amount value is null`() {
|
||||
val tx = uncompiledWith(
|
||||
type = AmountType.Token(makeToken(feeContract)),
|
||||
value = null,
|
||||
)
|
||||
|
||||
val result = either<GetFeeError, BigDecimal> {
|
||||
computeSendAmountInFeeToken(tx, feeContract)
|
||||
}
|
||||
|
||||
assertTrue(result.isLeft(), "Expected Left (error) when sent amount is null")
|
||||
assertTrue(
|
||||
result.leftOrNull() is GetFeeError.DataError,
|
||||
"Expected GetFeeError.DataError wrapping IllegalStateException",
|
||||
)
|
||||
}
|
||||
|
||||
// (e) Compiled tx → raises (gasless token-fee requires uncompiled data)
|
||||
@Test
|
||||
fun `raises when transactionData is Compiled`() {
|
||||
val compiled = TransactionData.Compiled(
|
||||
value = TransactionData.Compiled.Data.Bytes(byteArrayOf(0x01, 0x02)),
|
||||
)
|
||||
|
||||
val result = either<GetFeeError, BigDecimal> {
|
||||
computeSendAmountInFeeToken(compiled, feeContract)
|
||||
}
|
||||
|
||||
assertTrue(result.isLeft(), "Expected Left (error) for compiled tx")
|
||||
assertTrue(
|
||||
result.leftOrNull() is GetFeeError.DataError,
|
||||
"Expected GetFeeError.DataError wrapping IllegalStateException",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
/**
|
||||
* Unit tests for [CreateAndSendGaslessTransactionUseCase.getDestinationAddress] — resolves the on-chain
|
||||
* `to` of the user's main gasless sub-call.
|
||||
*
|
||||
* Regression guard: a yield-supply send must target the user's yield MODULE (the contract that
|
||||
* runs `send(token, dest, amount)`), not the transfer recipient. Targeting the recipient reverts the whole
|
||||
* batch with GAS_ESTIMATION_FAILED / require(false).
|
||||
*/
|
||||
internal class CreateAndSendGaslessDestinationAddressTest {
|
||||
|
||||
private val module = "0xmodule"
|
||||
private val recipient = "0xrecipient"
|
||||
private val tokenContract = "0xtokencontract"
|
||||
|
||||
private fun uncompiled(
|
||||
destinationAddress: String,
|
||||
extras: EthereumTransactionExtras?,
|
||||
contractAddress: String?,
|
||||
) = TransactionData.Uncompiled(
|
||||
amount = mockk(relaxed = true),
|
||||
fee = null,
|
||||
sourceAddress = "0xsource",
|
||||
destinationAddress = destinationAddress,
|
||||
extras = extras,
|
||||
contractAddress = contractAddress,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN yield-supply send WHEN getDestinationAddress THEN returns module not recipient`() {
|
||||
// Arrange — destinationAddress is patched to the yield module; the recipient lives inside the callData
|
||||
val yieldCallData = EthereumYieldSupplySendCallData(
|
||||
tokenContractAddress = tokenContract,
|
||||
destinationAddress = recipient,
|
||||
amount = mockk(relaxed = true),
|
||||
)
|
||||
val txData = uncompiled(
|
||||
destinationAddress = module,
|
||||
extras = EthereumTransactionExtras(callData = yieldCallData),
|
||||
contractAddress = tokenContract,
|
||||
)
|
||||
|
||||
// Act
|
||||
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
|
||||
|
||||
// Assert
|
||||
assertThat(to).isEqualTo(module)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN ERC20 transfer WHEN getDestinationAddress THEN returns token contract`() {
|
||||
// Arrange — a non-yield callData; `to` must be the token contract, not the recipient
|
||||
val erc20CallData = object : SmartContractCallData {
|
||||
override val methodId = "0xa9059cbb"
|
||||
override val data = byteArrayOf(0x01)
|
||||
override fun validate(blockchain: Blockchain) = true
|
||||
}
|
||||
val txData = uncompiled(
|
||||
destinationAddress = recipient,
|
||||
extras = EthereumTransactionExtras(callData = erc20CallData),
|
||||
contractAddress = tokenContract,
|
||||
)
|
||||
|
||||
// Act
|
||||
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
|
||||
|
||||
// Assert
|
||||
assertThat(to).isEqualTo(tokenContract)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-yield tx without contract address WHEN getDestinationAddress THEN throws`() {
|
||||
// Arrange
|
||||
val txData = uncompiled(
|
||||
destinationAddress = recipient,
|
||||
extras = null,
|
||||
contractAddress = null,
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertThrows<IllegalStateException> {
|
||||
CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessFeePlan
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase.GaslessPayload
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Unit tests for [CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload].
|
||||
* Pure function — no coroutines or SDK side-effects.
|
||||
*/
|
||||
internal class CreateAndSendGaslessPayloadTest {
|
||||
|
||||
// ─── Common fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
private val mainTx = GaslessTransactionData.Transaction(
|
||||
to = "0xmain",
|
||||
value = BigInteger.ZERO,
|
||||
gasLimit = BigInteger.valueOf(120_000),
|
||||
data = byteArrayOf(0x01, 0x02),
|
||||
)
|
||||
|
||||
private val withdrawGasLimit = BigInteger.valueOf(150_000)
|
||||
|
||||
private val feeObj = GaslessTransactionData.Fee(
|
||||
feeToken = "0xtoken",
|
||||
maxTokenFee = BigInteger.TEN,
|
||||
coinPriceInToken = BigInteger.ONE,
|
||||
feeTransferGasLimit = BigInteger.valueOf(60_000),
|
||||
baseGas = BigInteger.valueOf(21_000),
|
||||
feeReceiver = "0xrecv",
|
||||
)
|
||||
|
||||
private val nonce = BigInteger.valueOf(42)
|
||||
|
||||
// Minimal SmartContractCallData fake — only `data` is consumed by the SUT.
|
||||
private val fakeWithdrawCallData = object : SmartContractCallData {
|
||||
override val methodId: String = "0xfakeid"
|
||||
override val data: ByteArray = byteArrayOf(0x12, 0x34)
|
||||
override fun validate(blockchain: com.tangem.blockchain.common.Blockchain) = true
|
||||
}
|
||||
|
||||
private val fakeToken: CryptoCurrency.Token = mockk(relaxed = true)
|
||||
private val fakeTokenFee: Fee.Ethereum.TokenCurrency = mockk(relaxed = true)
|
||||
private val fakeNativeFee: Fee = mockk(relaxed = true)
|
||||
|
||||
// ─── Case 1: TokenPayWithYieldWithdraw → GaslessPayload.Batch ────────────────
|
||||
|
||||
@Test
|
||||
fun `TokenPayWithYieldWithdraw plan returns Batch with correct structure`() {
|
||||
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
|
||||
feeToken = fakeToken,
|
||||
fee = fakeTokenFee,
|
||||
withdrawAmount = BigInteger.valueOf(7_000_001),
|
||||
withdrawCallData = fakeWithdrawCallData,
|
||||
yieldModuleAddress = "0xmodule",
|
||||
)
|
||||
|
||||
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
|
||||
mainTx = mainTx,
|
||||
feeObj = feeObj,
|
||||
nonce = nonce,
|
||||
plan = plan,
|
||||
withdrawGasLimit = withdrawGasLimit,
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(GaslessPayload.Batch::class.java)
|
||||
val batch = (result as GaslessPayload.Batch).data
|
||||
|
||||
// transactions list has exactly 2 entries
|
||||
assertThat(batch.transactions).hasSize(2)
|
||||
|
||||
// index 0 is the unchanged main transaction
|
||||
assertThat(batch.transactions[0]).isEqualTo(mainTx)
|
||||
|
||||
// index 1 is the yield-withdraw transaction
|
||||
val withdrawTx = batch.transactions[1]
|
||||
assertThat(withdrawTx.to).isEqualTo(plan.yieldModuleAddress)
|
||||
assertThat(withdrawTx.value).isEqualTo(BigInteger.ZERO)
|
||||
assertThat(withdrawTx.gasLimit).isEqualTo(withdrawGasLimit)
|
||||
assertThat(withdrawTx.data).isEqualTo(fakeWithdrawCallData.data)
|
||||
|
||||
// fee and nonce are carried through
|
||||
assertThat(batch.fee).isEqualTo(feeObj)
|
||||
assertThat(batch.nonce).isEqualTo(nonce)
|
||||
}
|
||||
|
||||
// ─── Case 2: TokenPay → GaslessPayload.Single ────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `TokenPay plan returns Single wrapping mainTx feeObj and nonce`() {
|
||||
val plan = GaslessFeePlan.TokenPay(feeToken = fakeToken, fee = fakeTokenFee)
|
||||
|
||||
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
|
||||
mainTx = mainTx,
|
||||
feeObj = feeObj,
|
||||
nonce = nonce,
|
||||
plan = plan,
|
||||
withdrawGasLimit = null,
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
|
||||
val single = (result as GaslessPayload.Single).data
|
||||
assertThat(single.transaction).isEqualTo(mainTx)
|
||||
assertThat(single.fee).isEqualTo(feeObj)
|
||||
assertThat(single.nonce).isEqualTo(nonce)
|
||||
}
|
||||
|
||||
// ─── Case 3: null plan → GaslessPayload.Single (same as TokenPay) ───────────
|
||||
|
||||
@Test
|
||||
fun `null plan returns Single wrapping mainTx feeObj and nonce`() {
|
||||
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
|
||||
mainTx = mainTx,
|
||||
feeObj = feeObj,
|
||||
nonce = nonce,
|
||||
plan = null,
|
||||
withdrawGasLimit = null,
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
|
||||
val single = (result as GaslessPayload.Single).data
|
||||
assertThat(single.transaction).isEqualTo(mainTx)
|
||||
assertThat(single.fee).isEqualTo(feeObj)
|
||||
assertThat(single.nonce).isEqualTo(nonce)
|
||||
}
|
||||
|
||||
// ─── Case 4: NativePay → throws IllegalStateException ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `NativePay plan throws IllegalStateException`() {
|
||||
val plan = GaslessFeePlan.NativePay(fee = fakeNativeFee)
|
||||
|
||||
assertThrows<IllegalStateException> {
|
||||
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
|
||||
mainTx = mainTx,
|
||||
feeObj = feeObj,
|
||||
nonce = nonce,
|
||||
plan = plan,
|
||||
withdrawGasLimit = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Case 5: yield-withdraw plan without a withdraw gas limit → throws ────────
|
||||
|
||||
@Test
|
||||
fun `TokenPayWithYieldWithdraw plan without withdrawGasLimit throws IllegalStateException`() {
|
||||
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
|
||||
feeToken = fakeToken,
|
||||
fee = fakeTokenFee,
|
||||
withdrawAmount = BigInteger.valueOf(7_000_001),
|
||||
withdrawCallData = fakeWithdrawCallData,
|
||||
yieldModuleAddress = "0xmodule",
|
||||
)
|
||||
|
||||
assertThrows<IllegalStateException> {
|
||||
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
|
||||
mainTx = mainTx,
|
||||
feeObj = feeObj,
|
||||
nonce = nonce,
|
||||
plan = plan,
|
||||
withdrawGasLimit = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase.Companion.isEligibleFeeToken
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class GetAvailableFeeTokensUseCaseTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun isEligible(model: EligibilityModel) {
|
||||
// Arrange
|
||||
val status = createStatus(model.yieldSupplyStatus)
|
||||
|
||||
// Act
|
||||
val actual = isEligibleFeeToken(status, isYieldWithdrawEnabled = model.isYieldWithdrawEnabled)
|
||||
|
||||
// Assert
|
||||
assertThat(actual).isEqualTo(model.expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// Plain token (no yield status) is always eligible, regardless of the toggle.
|
||||
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = false, expected = true),
|
||||
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = true, expected = true),
|
||||
// Active yield: eligible only when gasless v2 (yield withdraw) is enabled.
|
||||
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = true),
|
||||
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
|
||||
// Inactive yield status: excluded either way (no module to withdraw from).
|
||||
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = false),
|
||||
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
|
||||
)
|
||||
|
||||
internal data class EligibilityModel(
|
||||
val yieldSupplyStatus: YieldSupplyStatus?,
|
||||
val isYieldWithdrawEnabled: Boolean,
|
||||
val expected: Boolean,
|
||||
)
|
||||
|
||||
private fun createStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
|
||||
val status = mockk<CryptoCurrencyStatus>()
|
||||
every { status.value.yieldSupplyStatus } returns yieldSupplyStatus
|
||||
return status
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_YIELD = YieldSupplyStatus(
|
||||
isActive = true,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("100"),
|
||||
)
|
||||
val INACTIVE_YIELD = ACTIVE_YIELD.copy(isActive = false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.GaslessFeePlan
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Unit tests for [ResolveGaslessFeePlanUseCase].
|
||||
* Covers every branch of the gasless fee decision tree.
|
||||
*/
|
||||
internal class ResolveGaslessFeePlanUseCaseTest {
|
||||
|
||||
private lateinit var gaslessYieldRepository: GaslessYieldRepository
|
||||
private lateinit var useCase: ResolveGaslessFeePlanUseCase
|
||||
|
||||
private val mockUserWalletId: UserWalletId = mockk(relaxed = true)
|
||||
private val mockUserWallet: UserWallet = mockk<UserWallet.Hot>().also {
|
||||
every { it.walletId } returns mockUserWalletId
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
gaslessYieldRepository = mockk()
|
||||
useCase = ResolveGaslessFeePlanUseCase(gaslessYieldRepository)
|
||||
}
|
||||
|
||||
// ─── Case 1: plain balance >= required → TokenPay ──────────────────────────
|
||||
|
||||
@Test
|
||||
fun `plain balance covers fee returns TokenPay`() = runTest {
|
||||
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = false,
|
||||
sendAmountInFeeToken = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val plan = result.getOrNull()
|
||||
assertThat(plan).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
|
||||
assertThat((plan as GaslessFeePlan.TokenPay).fee).isEqualTo(tokenFee)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain balance equals required returns TokenPay`() = runTest {
|
||||
val amount = BigDecimal("5")
|
||||
val tokenStatus = tokenStatus(plainBalance = amount, decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = amount, decimals = 6)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = false,
|
||||
sendAmountInFeeToken = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
|
||||
}
|
||||
|
||||
// ─── Case 2: yield-active with no liquid → the whole fee is withdrawn from the module ──
|
||||
|
||||
@Test
|
||||
fun `yield active with no liquid withdraws the whole fee`() = runTest {
|
||||
val decimals = 6
|
||||
// value.amount is effectiveBalance = liquid(EOA) + effectiveProtocolBalance. Here total == module
|
||||
// balance (20), so liquid is 0 and the entire fee must be withdrawn from the module — the plan must
|
||||
// not short-circuit to TokenPay.
|
||||
// withdraw == feeAmount, CEILING-rounded: 10000000.5 → 10000001 (floor would give 10000000).
|
||||
val feeAmount = BigDecimal("10.0000005")
|
||||
val moduleBalance = BigDecimal("20")
|
||||
val expectedWithdrawAmount = feeAmount
|
||||
.movePointRight(decimals)
|
||||
.setScale(0, RoundingMode.CEILING)
|
||||
.toBigInteger()
|
||||
val floorAmount = feeAmount.movePointRight(decimals).toBigInteger() // 10000000
|
||||
assertThat(expectedWithdrawAmount).isGreaterThan(floorAmount)
|
||||
|
||||
// value.amount == module balance → liquid is 0, so the fee cannot be paid from the EOA (no TokenPay).
|
||||
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
|
||||
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
|
||||
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns moduleBalance
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.createPartialWithdrawCallData(
|
||||
userWalletId = mockUserWalletId,
|
||||
cryptoCurrency = any(),
|
||||
amount = any(),
|
||||
)
|
||||
} returns mockCallData
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
|
||||
} returns "0xmodule"
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
|
||||
assertThat(plan).isNotNull()
|
||||
// Must be 10000001 (CEILING of the fee), not the module balance and not floor.
|
||||
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
|
||||
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(10_000_001))
|
||||
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
|
||||
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
|
||||
}
|
||||
|
||||
// ─── Case 2b: send amount counts toward sufficiency but NOT toward the withdraw ────────────
|
||||
|
||||
@Test
|
||||
fun `yield active withdraw covers only the fee not the send amount`() = runTest {
|
||||
val decimals = 6
|
||||
// The main module.send tx moves the send amount from the module itself, so the fee-withdraw must
|
||||
// cover ONLY the fee. Including the send amount would withdraw it twice and overdraw the module.
|
||||
val feeAmount = BigDecimal("3.0")
|
||||
val sendAmountInFeeToken = BigDecimal("1.5")
|
||||
val moduleBalance = BigDecimal("5.0") // covers required = fee(3.0) + send(1.5) = 4.5 ✓
|
||||
val expectedWithdrawAmount = feeAmount
|
||||
.movePointRight(decimals)
|
||||
.setScale(0, RoundingMode.CEILING)
|
||||
.toBigInteger() // 3000000 — the FEE only, NOT 4.5
|
||||
|
||||
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
|
||||
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
|
||||
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns moduleBalance
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.createPartialWithdrawCallData(
|
||||
userWalletId = mockUserWalletId,
|
||||
cryptoCurrency = any(),
|
||||
amount = any(),
|
||||
)
|
||||
} returns mockCallData
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
|
||||
} returns "0xmodule"
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = sendAmountInFeeToken,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
|
||||
assertThat(plan).isNotNull()
|
||||
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
|
||||
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(3_000_000))
|
||||
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
|
||||
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
|
||||
}
|
||||
|
||||
// ─── Case 2c: module cannot cover send + fee → NotEnoughFunds ──────────────
|
||||
|
||||
@Test
|
||||
fun `yield active module cannot cover send plus fee returns NotEnoughFunds`() = runTest {
|
||||
val tokenStatus = tokenStatus(plainBalance = BigDecimal("4"), decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = BigDecimal("3"), decimals = 6)
|
||||
|
||||
// required = fee(3) + send(1.5) = 4.5, but the module holds only 4.0
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns BigDecimal("4.0")
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = BigDecimal("1.5"),
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
|
||||
}
|
||||
|
||||
// ─── Case 3: plain insufficient, isYieldActive=false → NotEnoughFunds ──────
|
||||
|
||||
@Test
|
||||
fun `plain insufficient yield inactive returns NotEnoughFunds`() = runTest {
|
||||
val tokenStatus = tokenStatus(plainBalance = BigDecimal("1"), decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = false,
|
||||
sendAmountInFeeToken = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
|
||||
}
|
||||
|
||||
// ─── Case 4: YieldModuleUpgradeUnavailableException → ModuleUpdateUnavailable
|
||||
|
||||
@Test
|
||||
fun `createPartialWithdrawCallData throws UpgradeUnavailableException returns ModuleUpdateUnavailable`() = runTest {
|
||||
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
|
||||
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns BigDecimal("10")
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
|
||||
} throws YieldModuleUpgradeUnavailableException("0xold")
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
|
||||
}
|
||||
|
||||
// ─── Case 5: plain + yield < required → NotEnoughFunds ─────────────────────
|
||||
|
||||
@Test
|
||||
fun `plain plus yield insufficient returns NotEnoughFunds`() = runTest {
|
||||
// total(6) = liquid(1) + module(5) < fee(10) → not enough funds anywhere.
|
||||
val tokenStatus = tokenStatus(plainBalance = BigDecimal("6"), decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = BigDecimal("10"), decimals = 6)
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns BigDecimal("5") // liquid 1 + module 5 = 6 < 10
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
|
||||
}
|
||||
|
||||
// ─── Case 6: YieldModuleVersionIndeterminateException → ModuleUpdateUnavailable
|
||||
|
||||
@Test
|
||||
fun `createPartialWithdrawCallData throws VersionIndeterminateException returns ModuleUpdateUnavailable`() = runTest {
|
||||
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
|
||||
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns BigDecimal("10")
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
|
||||
} throws YieldModuleVersionIndeterminateException("rpc error")
|
||||
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
|
||||
}
|
||||
|
||||
// ─── Case 7: liquid EOA balance covers most of send+fee, protocol alone does not ──────────────
|
||||
|
||||
@Test
|
||||
fun `GIVEN liquid covers send but protocol alone does not WHEN yield active THEN TokenPayWithYieldWithdraw`() =
|
||||
runTest {
|
||||
// value.amount is effectiveBalance (liquid EOA + effectiveProtocolBalance). The user sends 3.00 of
|
||||
// 3.585624 total. The yield module (effectiveProtocolBalance) holds only 0.6, the rest (2.985624)
|
||||
// is liquid on the EOA. required = send(3.00) + fee(0.05) = 3.05 < total(3.585624), so funds ARE
|
||||
// sufficient. The old check compared the module balance (0.6) against required and wrongly raised
|
||||
// NotEnoughFunds.
|
||||
val decimals = 6
|
||||
val totalBalance = BigDecimal("3.585624")
|
||||
val moduleBalance = BigDecimal("0.6")
|
||||
val feeAmount = BigDecimal("0.05")
|
||||
val sendAmount = BigDecimal("3.00")
|
||||
// module.send consumes EOA liquid first, leaving 0 for the fee, so the whole fee must be withdrawn.
|
||||
val expectedWithdrawAmount = feeAmount
|
||||
.movePointRight(decimals)
|
||||
.setScale(0, RoundingMode.CEILING)
|
||||
.toBigInteger()
|
||||
|
||||
val tokenStatus = tokenStatus(plainBalance = totalBalance, decimals = decimals)
|
||||
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
|
||||
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns moduleBalance
|
||||
coEvery {
|
||||
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
|
||||
} returns mockCallData
|
||||
coEvery {
|
||||
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
|
||||
} returns "0xmodule"
|
||||
|
||||
// Act
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = sendAmount,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
|
||||
assertThat(plan).isNotNull()
|
||||
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
|
||||
}
|
||||
|
||||
// ─── Case 8: liquid EOA balance alone covers send + fee → no withdraw needed ───────────────────
|
||||
|
||||
@Test
|
||||
fun `GIVEN liquid covers send plus fee WHEN yield active THEN TokenPay without withdraw`() = runTest {
|
||||
// Arrange — liquid = total(10) - module(2) = 8, which already covers required = send(3) + fee(1) = 4.
|
||||
// The EOA holds enough after the main send to settle the fee, so no yield withdraw is needed.
|
||||
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
|
||||
val tokenFee = tokenFee(feeAmount = BigDecimal("1"), decimals = 6)
|
||||
|
||||
coEvery {
|
||||
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
|
||||
} returns BigDecimal("2")
|
||||
|
||||
// Act
|
||||
val result = useCase(
|
||||
userWallet = mockUserWallet,
|
||||
tokenStatus = tokenStatus,
|
||||
tokenFee = tokenFee,
|
||||
isYieldActive = true,
|
||||
sendAmountInFeeToken = BigDecimal("3"),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun tokenStatus(
|
||||
plainBalance: BigDecimal = BigDecimal("100"),
|
||||
decimals: Int = 6,
|
||||
): CryptoCurrencyStatus {
|
||||
val token = mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
every { token.symbol } returns "USDC"
|
||||
every { token.contractAddress } returns "0xUSDC"
|
||||
every { token.decimals } returns decimals
|
||||
|
||||
val status = mockk<CryptoCurrencyStatus>()
|
||||
every { status.currency } returns token
|
||||
every { status.value.amount } returns plainBalance
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
private fun tokenFee(feeAmount: BigDecimal, decimals: Int = 6): Fee.Ethereum.TokenCurrency {
|
||||
val blockchainToken = Token(symbol = "USDC", contractAddress = "0xUSDC", decimals = decimals)
|
||||
val amount = Amount(token = blockchainToken, value = feeAmount)
|
||||
return Fee.Ethereum.TokenCurrency(
|
||||
amount = amount,
|
||||
gasLimit = BigInteger("100000"),
|
||||
coinPriceInToken = BigInteger("2000000000"),
|
||||
feeTransferGasLimit = BigInteger("60000"),
|
||||
baseGas = BigInteger("21000"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SmartContractCallData>(relaxed = true)
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
isYieldActive = true,
|
||||
userWallet = mockUserWallet,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isRight(), "Expected success on yield path with on-chain withdraw estimation")
|
||||
result.onRight { feeExtended ->
|
||||
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
|
||||
// gasLimit = 100_000 + 66_000 + 21_000 + 200_000 = 387_000
|
||||
assertEquals(BigInteger("387000"), fee.gasLimit, "gasLimit must include the estimated withdraw gas")
|
||||
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = estimated 200_000
|
||||
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
|
||||
assertEquals(BigInteger("200000"), feeExtended.withdrawGasLimit)
|
||||
}
|
||||
coVerify { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) }
|
||||
coVerify { mockWalletManager.getGasLimit(any(), "0xModule", any()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* When the yield module address is unavailable (e.g. module not yet deployed), the on-chain estimation
|
||||
* is skipped and the calculator falls back to [WITHDRAW_GAS_LIMIT] — even though a wallet is provided.
|
||||
*/
|
||||
@Test
|
||||
fun `calculateTokenFee with active yield falls back when yield module address is unavailable`() = runTest {
|
||||
// Given
|
||||
val activeYieldStatus = YieldSupplyStatus(
|
||||
isActive = true,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("100"),
|
||||
)
|
||||
val tokenStatus = createMockTokenStatus(
|
||||
balance = BigDecimal("0.001"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
).withYieldSupplyStatus(activeYieldStatus)
|
||||
|
||||
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
|
||||
val initialFee = createMockEIP1559Fee()
|
||||
|
||||
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||
coEvery { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) } returns null
|
||||
|
||||
// When
|
||||
val result = tokenFeeCalculator.calculateTokenFee(
|
||||
walletManager = mockWalletManager,
|
||||
tokenForPayFeeStatus = tokenStatus,
|
||||
nativeCurrencyStatus = nativeStatus,
|
||||
initialFee = initialFee,
|
||||
isYieldActive = true,
|
||||
userWallet = mockUserWallet,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isRight())
|
||||
result.onRight { feeExtended ->
|
||||
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 (fallback) = 337_000
|
||||
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
|
||||
assertEquals(BigInteger("337000"), fee.gasLimit)
|
||||
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
|
||||
}
|
||||
// withdraw estimation must NOT be attempted without a module address
|
||||
coVerify(exactly = 0) { gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
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<CryptoCurrencyStatus>()
|
||||
every { newStatus.currency } returns original.currency
|
||||
every { newStatus.value.amount } returns original.value.amount
|
||||
every { newStatus.value.fiatRate } returns original.value.fiatRate
|
||||
every { newStatus.value.yieldSupplyStatus } returns yieldSupplyStatus
|
||||
return newStatus
|
||||
}
|
||||
|
||||
private fun createMockNativeCurrencyStatus(
|
||||
fiatRate: BigDecimal? = BigDecimal("2000"),
|
||||
decimals: Int = 18,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.exchange
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.SpacerWMax
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState
|
||||
import com.tangem.features.tokendetails.impl.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Deprecated("Use ExpressStatusBlock from common")
|
||||
@Composable
|
||||
internal fun ExchangeStatusBlock(
|
||||
statuses: ImmutableList<ExchangeStatusState>,
|
||||
showLink: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.padding(
|
||||
vertical = TangemTheme.dimens.spacing14,
|
||||
horizontal = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(bottom = TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(id = R.string.express_exchange_status_title),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
SpacerWMax()
|
||||
AnimatedVisibility(visible = showLink) {
|
||||
Row(
|
||||
modifier = Modifier.clickable { onClick() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow_top_right_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.spacing16)
|
||||
.padding(end = TangemTheme.dimens.spacing2),
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(id = R.string.common_go_to_provider),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(targetState = statuses.lastIndex, label = "Exchange Status List Change") {
|
||||
Column {
|
||||
statuses.forEachIndexed { index, item ->
|
||||
ExchangeStatusStep(
|
||||
stepStatus = item,
|
||||
isLast = index == it,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExchangeStatusStep(
|
||||
stepStatus: ExchangeStatusState,
|
||||
modifier: Modifier = Modifier,
|
||||
isLast: Boolean = false,
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = stepStatus,
|
||||
label = "Exchange Step Change Success",
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size20),
|
||||
) { state ->
|
||||
when {
|
||||
state.status == ExchangeStatus.Cancelled -> {
|
||||
ExchangeStep(
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
color = TangemTheme.colors.icon.warning,
|
||||
isDone = false,
|
||||
)
|
||||
}
|
||||
state.status.isFailed() ||
|
||||
state.status == ExchangeStatus.Refunded ||
|
||||
state.status == ExchangeStatus.Paused
|
||||
-> {
|
||||
ExchangeStep(
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
color = TangemTheme.colors.icon.warning,
|
||||
isDone = state.isDone,
|
||||
)
|
||||
}
|
||||
state.status == ExchangeStatus.Verifying -> ExchangeStep(
|
||||
iconRes = R.drawable.ic_exclamation_24,
|
||||
color = TangemTheme.colors.icon.attention,
|
||||
isDone = state.isDone,
|
||||
)
|
||||
state.isDone -> ExchangeStep(
|
||||
iconRes = R.drawable.ic_check_24,
|
||||
color = TangemTheme.colors.icon.primary1,
|
||||
isDone = true,
|
||||
)
|
||||
state.isActive -> ExchangeStepInProgress()
|
||||
else -> ExchangeStepDefault()
|
||||
}
|
||||
}
|
||||
if (!isLast) {
|
||||
ExchangeStepSeparator()
|
||||
}
|
||||
}
|
||||
ExchangeStatusStepText(stepStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) {
|
||||
val status = stepStatus.status
|
||||
|
||||
val textColor = when {
|
||||
status == ExchangeStatus.Cancelled || status == ExchangeStatus.Refunded || status == ExchangeStatus.Paused -> {
|
||||
TangemTheme.colors.icon.warning
|
||||
}
|
||||
status.isFailed() && !stepStatus.isDone -> TangemTheme.colors.icon.warning
|
||||
status == ExchangeStatus.Verifying && !stepStatus.isDone -> TangemTheme.colors.icon.attention
|
||||
stepStatus.isDone -> TangemTheme.colors.text.primary1
|
||||
!stepStatus.isActive -> TangemTheme.colors.text.disabled
|
||||
else -> TangemTheme.colors.text.primary1
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stepStatus.text.resolveReference(),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens.spacing12),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExchangeStepDefault() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.border(
|
||||
width = TangemTheme.dimens.size1_5,
|
||||
color = TangemTheme.colors.field.focused,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.padding(TangemTheme.dimens.spacing2),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExchangeStep(color: Color, @DrawableRes iconRes: Int, isDone: Boolean) {
|
||||
val (iconColor, borderColor) = if (isDone) {
|
||||
TangemTheme.colors.icon.primary1 to TangemTheme.colors.field.focused
|
||||
} else {
|
||||
color to color
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = null,
|
||||
tint = iconColor,
|
||||
modifier = Modifier
|
||||
.border(
|
||||
width = TangemTheme.dimens.size1_5,
|
||||
color = borderColor,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.padding(TangemTheme.dimens.spacing2),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExchangeStepInProgress() {
|
||||
CircularProgressIndicator(
|
||||
color = TangemTheme.colors.icon.primary1,
|
||||
strokeWidth = TangemTheme.dimens.size2,
|
||||
modifier = Modifier
|
||||
.padding(TangemTheme.dimens.spacing2)
|
||||
.size(TangemTheme.dimens.size14),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExchangeStepSeparator() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = TangemTheme.dimens.spacing2)
|
||||
.size(
|
||||
width = TangemTheme.dimens.size1_5,
|
||||
height = TangemTheme.dimens.size10,
|
||||
)
|
||||
.background(
|
||||
color = TangemTheme.colors.field.focused,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview_ExchangeStatusBlock() {
|
||||
val base = ExchangeStatusState(
|
||||
status = ExchangeStatus.Failed,
|
||||
text = resourceReference(id = R.string.express_exchange_status_failed),
|
||||
isActive = true,
|
||||
isDone = false,
|
||||
)
|
||||
|
||||
TangemThemePreview {
|
||||
ExchangeStatusBlock(
|
||||
statuses = listOf(
|
||||
base,
|
||||
base.copy(isActive = false, isDone = false),
|
||||
base.copy(isActive = true, isDone = false),
|
||||
base.copy(isActive = true, isDone = true),
|
||||
ExchangeStatusState(
|
||||
status = ExchangeStatus.Paused,
|
||||
text = resourceReference(id = R.string.express_exchange_status_paused),
|
||||
isActive = true,
|
||||
isDone = false,
|
||||
),
|
||||
)
|
||||
.toImmutableList(),
|
||||
showLink = false,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue