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.card.repository.CardSdkConfigRepository
|
||||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
import com.tangem.domain.demo.models.DemoConfig
|
import com.tangem.domain.demo.models.DemoConfig
|
||||||
|
import com.tangem.core.configtoggle.FeatureToggles
|
||||||
|
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||||
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
|
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
|
||||||
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
|
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
|
||||||
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
|
import com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase
|
||||||
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
||||||
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
|
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
|
||||||
import com.tangem.domain.notifications.repository.PushNotificationsRepository
|
import com.tangem.domain.notifications.repository.PushNotificationsRepository
|
||||||
|
|
@ -319,30 +323,50 @@ internal object TransactionDomainModule {
|
||||||
gaslessTransactionRepository: GaslessTransactionRepository,
|
gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
currencyChecksRepository: CurrencyChecksRepository,
|
currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
featureTogglesManager: FeatureTogglesManager,
|
||||||
): GetAvailableFeeTokensUseCase {
|
): GetAvailableFeeTokensUseCase {
|
||||||
return GetAvailableFeeTokensUseCase(
|
return GetAvailableFeeTokensUseCase(
|
||||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
currencyChecksRepository = currencyChecksRepository,
|
currencyChecksRepository = currencyChecksRepository,
|
||||||
|
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideResolveGaslessFeePlanUseCase(
|
||||||
|
gaslessYieldRepository: GaslessYieldRepository,
|
||||||
|
): ResolveGaslessFeePlanUseCase {
|
||||||
|
return ResolveGaslessFeePlanUseCase(gaslessYieldRepository = gaslessYieldRepository)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideGetFeeForGaslessUseCase(
|
fun provideGetFeeForGaslessUseCase(
|
||||||
walletManagersFacade: WalletManagersFacade,
|
walletManagersFacade: WalletManagersFacade,
|
||||||
gaslessTransactionRepository: GaslessTransactionRepository,
|
gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository: GaslessYieldRepository,
|
||||||
getFeeUseCase: GetFeeUseCase,
|
getFeeUseCase: GetFeeUseCase,
|
||||||
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
currencyChecksRepository: CurrencyChecksRepository,
|
currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
|
||||||
|
featureTogglesManager: FeatureTogglesManager,
|
||||||
): GetFeeForGaslessUseCase {
|
): GetFeeForGaslessUseCase {
|
||||||
return GetFeeForGaslessUseCase(
|
return GetFeeForGaslessUseCase(
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
demoConfig = DemoConfig,
|
demoConfig = DemoConfig,
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||||
getFeeUseCase = getFeeUseCase,
|
getFeeUseCase = getFeeUseCase,
|
||||||
currencyChecksRepository = currencyChecksRepository,
|
currencyChecksRepository = currencyChecksRepository,
|
||||||
|
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
|
||||||
|
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,15 +375,23 @@ internal object TransactionDomainModule {
|
||||||
fun provideGetFeeForTokenUseCase(
|
fun provideGetFeeForTokenUseCase(
|
||||||
walletManagersFacade: WalletManagersFacade,
|
walletManagersFacade: WalletManagersFacade,
|
||||||
gaslessTransactionRepository: GaslessTransactionRepository,
|
gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository: GaslessYieldRepository,
|
||||||
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
currencyChecksRepository: CurrencyChecksRepository,
|
currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
|
||||||
|
featureTogglesManager: FeatureTogglesManager,
|
||||||
): GetFeeForTokenUseCase {
|
): GetFeeForTokenUseCase {
|
||||||
return GetFeeForTokenUseCase(
|
return GetFeeForTokenUseCase(
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
demoConfig = DemoConfig,
|
demoConfig = DemoConfig,
|
||||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||||
currencyChecksRepository = currencyChecksRepository,
|
currencyChecksRepository = currencyChecksRepository,
|
||||||
|
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
|
||||||
|
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +411,7 @@ internal object TransactionDomainModule {
|
||||||
singleAccountListSupplier: SingleAccountListSupplier,
|
singleAccountListSupplier: SingleAccountListSupplier,
|
||||||
cardSdkConfigRepository: CardSdkConfigRepository,
|
cardSdkConfigRepository: CardSdkConfigRepository,
|
||||||
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
|
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
|
||||||
|
featureTogglesManager: FeatureTogglesManager,
|
||||||
): CreateAndSendGaslessTransactionUseCase {
|
): CreateAndSendGaslessTransactionUseCase {
|
||||||
return CreateAndSendGaslessTransactionUseCase(
|
return CreateAndSendGaslessTransactionUseCase(
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
|
|
@ -386,6 +419,9 @@ internal object TransactionDomainModule {
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
cardSdkConfigRepository = cardSdkConfigRepository,
|
cardSdkConfigRepository = cardSdkConfigRepository,
|
||||||
getHotWalletSigner = tangemHotWalletSignerFactory::create,
|
getHotWalletSigner = tangemHotWalletSignerFactory::create,
|
||||||
|
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,15 +430,21 @@ internal object TransactionDomainModule {
|
||||||
fun provideEstimateFeeForTokenUseCase(
|
fun provideEstimateFeeForTokenUseCase(
|
||||||
walletManagersFacade: WalletManagersFacade,
|
walletManagersFacade: WalletManagersFacade,
|
||||||
gaslessTransactionRepository: GaslessTransactionRepository,
|
gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository: GaslessYieldRepository,
|
||||||
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
currencyChecksRepository: CurrencyChecksRepository,
|
currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
featureTogglesManager: FeatureTogglesManager,
|
||||||
): EstimateFeeForTokenUseCase {
|
): EstimateFeeForTokenUseCase {
|
||||||
return EstimateFeeForTokenUseCase(
|
return EstimateFeeForTokenUseCase(
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
demoConfig = DemoConfig,
|
demoConfig = DemoConfig,
|
||||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||||
currencyChecksRepository = currencyChecksRepository,
|
currencyChecksRepository = currencyChecksRepository,
|
||||||
|
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,12 +453,14 @@ internal object TransactionDomainModule {
|
||||||
fun provideEstimateFeeForGaslessTxUseCase(
|
fun provideEstimateFeeForGaslessTxUseCase(
|
||||||
walletManagersFacade: WalletManagersFacade,
|
walletManagersFacade: WalletManagersFacade,
|
||||||
gaslessTransactionRepository: GaslessTransactionRepository,
|
gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository: GaslessYieldRepository,
|
||||||
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
estimateFeeUseCase: EstimateFeeUseCase,
|
estimateFeeUseCase: EstimateFeeUseCase,
|
||||||
currencyChecksRepository: CurrencyChecksRepository,
|
currencyChecksRepository: CurrencyChecksRepository,
|
||||||
): EstimateFeeForGaslessTxUseCase {
|
): EstimateFeeForGaslessTxUseCase {
|
||||||
return EstimateFeeForGaslessTxUseCase(
|
return EstimateFeeForGaslessTxUseCase(
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
demoConfig = DemoConfig,
|
demoConfig = DemoConfig,
|
||||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,10 @@
|
||||||
"name": "TWI_83_ADDRESS_BOOK_ENABLED",
|
"name": "TWI_83_ADDRESS_BOOK_ENABLED",
|
||||||
"version": "undefined"
|
"version": "undefined"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED",
|
||||||
|
"version": "undefined"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED",
|
"name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED",
|
||||||
"version": "6.0"
|
"version": "6.0"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
@Json(name = "value")
|
||||||
val value: String,
|
val value: String,
|
||||||
|
|
||||||
|
@Json(name = "gasLimit")
|
||||||
|
val gasLimit: String? = null,
|
||||||
|
|
||||||
@Json(name = "data")
|
@Json(name = "data")
|
||||||
val data: String,
|
val data: String,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import com.tangem.datasource.api.news.NewsApi
|
||||||
import com.tangem.datasource.api.onramp.OnrampApi
|
import com.tangem.datasource.api.onramp.OnrampApi
|
||||||
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||||
|
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||||
import com.tangem.datasource.api.pay.TangemPayApi
|
import com.tangem.datasource.api.pay.TangemPayApi
|
||||||
import com.tangem.datasource.api.pay.TangemPayAuthApi
|
import com.tangem.datasource.api.pay.TangemPayAuthApi
|
||||||
import com.tangem.datasource.api.stakekit.StakeKitApi
|
import com.tangem.datasource.api.stakekit.StakeKitApi
|
||||||
|
|
@ -252,4 +253,20 @@ internal object NetworkModule {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGaslessTxServiceApiV2(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApiV2 {
|
||||||
|
return retrofitApiBuilder.build(
|
||||||
|
apiConfigId = ApiConfig.ID.GaslessTxService,
|
||||||
|
applyTimeoutAnnotations = false,
|
||||||
|
sessionAuth = false,
|
||||||
|
timeouts = Timeouts(
|
||||||
|
callTimeoutSeconds = TIMEOUT_60_SECONDS,
|
||||||
|
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
|
||||||
|
readTimeoutSeconds = TIMEOUT_60_SECONDS,
|
||||||
|
writeTimeoutSeconds = TIMEOUT_60_SECONDS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ dependencies {
|
||||||
implementation(tangemDeps.card.core)
|
implementation(tangemDeps.card.core)
|
||||||
|
|
||||||
/** Core */
|
/** Core */
|
||||||
|
implementation(projects.core.configToggles)
|
||||||
implementation(projects.core.datasource)
|
implementation(projects.core.datasource)
|
||||||
implementation(projects.core.utils)
|
implementation(projects.core.utils)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,23 @@ package com.tangem.data.transaction
|
||||||
import com.tangem.blockchain.common.Token
|
import com.tangem.blockchain.common.Token
|
||||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||||
|
import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder
|
||||||
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
|
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
|
||||||
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
|
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
|
||||||
|
import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter
|
||||||
import com.tangem.datasource.api.common.response.getOrThrow
|
import com.tangem.datasource.api.common.response.getOrThrow
|
||||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||||
|
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||||
|
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.coroutines.runSuspendCatching
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
|
@ -23,17 +29,19 @@ import java.math.BigInteger
|
||||||
|
|
||||||
class DefaultGaslessTransactionRepository(
|
class DefaultGaslessTransactionRepository(
|
||||||
private val gaslessTxServiceApi: GaslessTxServiceApi,
|
private val gaslessTxServiceApi: GaslessTxServiceApi,
|
||||||
|
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||||
|
private val isGaslessV2Enabled: Boolean,
|
||||||
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||||
) : GaslessTransactionRepository {
|
) : GaslessTransactionRepository {
|
||||||
|
|
||||||
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
|
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
|
||||||
private val allFeeRecipientAddress = mutableSetOf<String>()
|
|
||||||
private val allAddressesMutex = Mutex()
|
|
||||||
private val receiverAddressMutex = Mutex()
|
private val receiverAddressMutex = Mutex()
|
||||||
private var feeReceiverAddress: String? = null
|
private var feeReceiverAddress: String? = null
|
||||||
|
|
||||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
|
private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled)
|
||||||
|
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter)
|
||||||
|
private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter)
|
||||||
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
|
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
|
||||||
|
|
||||||
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
||||||
|
|
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
|
||||||
eip7702Auth = eip7702Auth,
|
eip7702Auth = eip7702Auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
|
val response = if (isGaslessV2Enabled) {
|
||||||
|
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
|
||||||
|
} else {
|
||||||
|
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
|
||||||
|
}.getOrThrow()
|
||||||
|
|
||||||
if (!response.isSuccess) {
|
if (!response.isSuccess) {
|
||||||
error("Gasless service returned unsuccessful response")
|
error("Gasless service returned unsuccessful response")
|
||||||
|
|
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
|
||||||
signedTransactionResultConverter.convert(response.result)
|
signedTransactionResultConverter.convert(response.result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun signGaslessBatchTransaction(
|
||||||
|
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||||
|
signature: String,
|
||||||
|
userAddress: String,
|
||||||
|
network: Network,
|
||||||
|
eip7702Auth: Eip7702Authorization?,
|
||||||
|
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
|
||||||
|
val blockchain = network.toBlockchain()
|
||||||
|
val transactionRequest = gaslessBatchTransactionRequestBuilder.build(
|
||||||
|
gaslessBatchTransaction = gaslessBatchTransactionData,
|
||||||
|
signature = signature,
|
||||||
|
userAddress = userAddress,
|
||||||
|
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
|
||||||
|
eip7702Auth = eip7702Auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow()
|
||||||
|
|
||||||
|
if (!response.isSuccess) {
|
||||||
|
error("Gasless service returned unsuccessful response")
|
||||||
|
}
|
||||||
|
|
||||||
|
signedTransactionResultConverter.convert(response.result)
|
||||||
|
}
|
||||||
|
|
||||||
override fun getBaseGasForTransaction(): BigInteger {
|
override fun getBaseGasForTransaction(): BigInteger {
|
||||||
return BASE_GAS_FOR_TRANSACTION
|
return BASE_GAS_FOR_TRANSACTION
|
||||||
}
|
}
|
||||||
|
|
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
||||||
return allAddressesMutex.withLock {
|
|
||||||
allFeeRecipientAddress.ifEmpty {
|
|
||||||
val allFeeAddresses = getAllFeeRecipientAddresses()
|
|
||||||
allFeeRecipientAddress.addAll(allFeeAddresses)
|
|
||||||
allFeeRecipientAddress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
|
|
||||||
// TODO Replace with other backend call to get all fee recipient addresses when available
|
// TODO Replace with other backend call to get all fee recipient addresses when available
|
||||||
return setOf(getTokenFeeReceiverAddress())
|
val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() }
|
||||||
|
.onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) }
|
||||||
|
.getOrNull()
|
||||||
|
return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
|
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
|
||||||
|
|
||||||
|
|
||||||
|
val KNOWN_FEE_COLLECTION_ADDRESSES = setOf(
|
||||||
|
"0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c",
|
||||||
|
"0xAf722F46145fbb106379d506ED3a5B96f110c8E5",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||||
|
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
|
||||||
txHash = "0x000",
|
txHash = "0x000",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override suspend fun signGaslessBatchTransaction(
|
||||||
|
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||||
|
signature: String,
|
||||||
|
userAddress: String,
|
||||||
|
network: Network,
|
||||||
|
eip7702Auth: Eip7702Authorization?,
|
||||||
|
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
|
||||||
|
txHash = "0x000",
|
||||||
|
)
|
||||||
|
|
||||||
override fun getBaseGasForTransaction(): BigInteger {
|
override fun getBaseGasForTransaction(): BigInteger {
|
||||||
return BASE_GAS_FOR_TRANSACTION
|
return BASE_GAS_FOR_TRANSACTION
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.datasource.api.gasless.models.GaslessTransactionRequest
|
||||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builder for creating complete GaslessTransactionRequest from domain model.
|
* Builder for creating complete GaslessTransactionRequest from domain model.
|
||||||
|
|
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||||
*/
|
*/
|
||||||
class GaslessTransactionRequestBuilder(
|
class GaslessTransactionRequestBuilder(
|
||||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||||
|
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
|
||||||
signature = signature,
|
signature = signature,
|
||||||
userAddress = userAddress,
|
userAddress = userAddress,
|
||||||
chainId = chainId,
|
chainId = chainId,
|
||||||
eip7702Auth = eip7702Auth?.toDTO(),
|
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts domain Eip7702Authorization to DTO.
|
|
||||||
*/
|
|
||||||
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
|
|
||||||
return Eip7702AuthorizationDTO(
|
|
||||||
chainId = chainId,
|
|
||||||
address = address,
|
|
||||||
nonce = nonce.toString(),
|
|
||||||
yParity = yParity,
|
|
||||||
r = r,
|
|
||||||
s = s,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
|
||||||
* Note: This converter only handles the transaction data conversion.
|
* Note: This converter only handles the transaction data conversion.
|
||||||
* Additional fields (signature, userAddress, chainId) must be added separately
|
* Additional fields (signature, userAddress, chainId) must be added separately
|
||||||
* to create complete GaslessTransactionRequest.
|
* to create complete GaslessTransactionRequest.
|
||||||
|
*
|
||||||
|
* @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the
|
||||||
|
* request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed.
|
||||||
*/
|
*/
|
||||||
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
class GaslessTxDataToGaslessRequestConverter(
|
||||||
|
private val shouldIncludeGasLimit: Boolean = true,
|
||||||
|
) : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||||
|
|
||||||
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
|
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
|
||||||
return GaslessTransactionDataDTO(
|
return GaslessTransactionDataDTO(
|
||||||
|
|
@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
internal fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||||
return TransactionData(
|
return TransactionData(
|
||||||
to = transaction.to,
|
to = transaction.to,
|
||||||
value = transaction.value.toString(),
|
value = transaction.value.toString(),
|
||||||
|
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
|
||||||
data = transaction.data.toHexString().formatHex(),
|
data = transaction.data.toHexString().formatHex(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||||
return FeeData(
|
return FeeData(
|
||||||
feeToken = fee.feeToken,
|
feeToken = fee.feeToken,
|
||||||
maxTokenFee = fee.maxTokenFee.toString(),
|
maxTokenFee = fee.maxTokenFee.toString(),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||||
import com.tangem.data.transaction.*
|
import com.tangem.data.transaction.*
|
||||||
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
|
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
|
||||||
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
||||||
|
import com.tangem.core.configtoggle.FeatureToggles
|
||||||
|
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||||
|
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||||
import com.tangem.domain.demo.models.DemoConfig
|
import com.tangem.domain.demo.models.DemoConfig
|
||||||
|
|
@ -84,10 +87,17 @@ internal object TransactionDataModule {
|
||||||
fun provideGaslessTransactionRepository(
|
fun provideGaslessTransactionRepository(
|
||||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||||
gaslessTxServiceApi: GaslessTxServiceApi,
|
gaslessTxServiceApi: GaslessTxServiceApi,
|
||||||
|
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||||
|
featureTogglesManager: FeatureTogglesManager,
|
||||||
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||||
): GaslessTransactionRepository {
|
): GaslessTransactionRepository {
|
||||||
return DefaultGaslessTransactionRepository(
|
return DefaultGaslessTransactionRepository(
|
||||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||||
|
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||||
|
// Single master toggle for the whole gasless v2 protocol (+ yield-withdraw batch).
|
||||||
|
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||||
|
),
|
||||||
coroutineDispatcherProvider = coroutineDispatcherProvider,
|
coroutineDispatcherProvider = coroutineDispatcherProvider,
|
||||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
implementation(projects.core.analytics)
|
||||||
|
|
||||||
/** Domain */
|
/** Domain */
|
||||||
|
implementation(projects.domain.transaction)
|
||||||
implementation(projects.domain.yieldSupply)
|
implementation(projects.domain.yieldSupply)
|
||||||
implementation(projects.domain.yieldSupply.models)
|
implementation(projects.domain.yieldSupply.models)
|
||||||
implementation(projects.domain.walletManager)
|
implementation(projects.domain.walletManager)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import com.tangem.blockchain.common.*
|
||||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||||
import com.tangem.blockchain.common.transaction.Fee
|
import com.tangem.blockchain.common.transaction.Fee
|
||||||
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
||||||
|
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
|
||||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
|
@ -238,6 +239,37 @@ internal class DefaultYieldSupplyTransactionRepository(
|
||||||
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
|
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getYieldModuleVersionStatus(
|
||||||
|
userWalletId: UserWalletId,
|
||||||
|
network: Network,
|
||||||
|
): YieldModuleVersionStatus = withContext(dispatchers.io) {
|
||||||
|
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
blockchain = network.toBlockchain(),
|
||||||
|
derivationPath = network.derivationPath.value,
|
||||||
|
) ?: error("Wallet manager not found for $network")
|
||||||
|
walletManager.checkModuleVersionStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createPartialWithdrawCallData(
|
||||||
|
userWalletId: UserWalletId,
|
||||||
|
cryptoCurrency: CryptoCurrency,
|
||||||
|
amount: Amount,
|
||||||
|
): SmartContractCallData = withContext(dispatchers.io) {
|
||||||
|
require(cryptoCurrency is CryptoCurrency.Token)
|
||||||
|
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
blockchain = cryptoCurrency.network.toBlockchain(),
|
||||||
|
derivationPath = cryptoCurrency.network.derivationPath.value,
|
||||||
|
) ?: error("Wallet manager not found")
|
||||||
|
val withdrawCallData = YieldSupplyContractCallDataProviderFactory.getWithdrawCallData(
|
||||||
|
tokenContractAddress = cryptoCurrency.contractAddress,
|
||||||
|
amount = amount,
|
||||||
|
)
|
||||||
|
val versionStatus = walletManager.checkModuleVersionStatus()
|
||||||
|
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, withdrawCallData)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun getYieldTokenStatus(
|
private suspend fun getYieldTokenStatus(
|
||||||
walletManager: WalletManager,
|
walletManager: WalletManager,
|
||||||
cryptoCurrency: CryptoCurrency.Token,
|
cryptoCurrency: CryptoCurrency.Token,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
|
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
|
||||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
|
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
|
||||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
|
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
|
||||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||||
|
|
@ -41,6 +42,14 @@ internal object YieldSupplyDataModule {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGaslessYieldRepository(
|
||||||
|
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
|
||||||
|
): GaslessYieldRepository {
|
||||||
|
return yieldSupplyTransactionRepository
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideYieldSupplyMarketRepository(
|
fun provideYieldSupplyMarketRepository(
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@ package com.tangem.data.yield.supply
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||||
|
import com.tangem.blockchain.common.Amount
|
||||||
|
import com.tangem.blockchain.common.AmountType
|
||||||
import com.tangem.blockchain.common.Blockchain
|
import com.tangem.blockchain.common.Blockchain
|
||||||
import com.tangem.blockchain.common.Token
|
import com.tangem.blockchain.common.Token
|
||||||
import com.tangem.blockchain.common.WalletManager
|
import com.tangem.blockchain.common.WalletManager
|
||||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
|
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
|
||||||
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
||||||
|
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
|
|
@ -306,4 +309,35 @@ class DefaultYieldSupplyTransactionRepositoryTest {
|
||||||
Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java)
|
Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java)
|
||||||
Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data)
|
Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createPartialWithdrawCallData returns withdraw call data when module is up to date`() = runTest {
|
||||||
|
coEvery { walletManager.checkModuleVersionStatus() } returns YieldModuleVersionStatus.UpToDate
|
||||||
|
|
||||||
|
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||||
|
every { contractAddress } returns mockedContractAddress
|
||||||
|
every { decimals } returns 6
|
||||||
|
}
|
||||||
|
val amount = Amount(
|
||||||
|
currencySymbol = "USDC",
|
||||||
|
value = BigDecimal("1.5"),
|
||||||
|
decimals = 6,
|
||||||
|
type = AmountType.Token(
|
||||||
|
token = Token(
|
||||||
|
symbol = "USDC",
|
||||||
|
contractAddress = mockedContractAddress,
|
||||||
|
decimals = 6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.createPartialWithdrawCallData(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
cryptoCurrency = token,
|
||||||
|
amount = amount,
|
||||||
|
)
|
||||||
|
|
||||||
|
Truth.assertThat(result).isNotNull()
|
||||||
|
Truth.assertThat(result.methodId).isEqualTo("0xf3fef3a3")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,5 +251,15 @@
|
||||||
"info": "GaslessTransactions",
|
"info": "GaslessTransactions",
|
||||||
"source": "https://github.com/tangem-developments/tangem-gasless-service",
|
"source": "https://github.com/tangem-developments/tangem-gasless-service",
|
||||||
"name": "gaslessTransaction"
|
"name": "gaslessTransaction"
|
||||||
|
},
|
||||||
|
"0x4b072692": {
|
||||||
|
"info": "GaslessTransactions",
|
||||||
|
"source": "https://github.com/tangem-developments/tangem-gasless-service",
|
||||||
|
"name": "gaslessTransaction"
|
||||||
|
},
|
||||||
|
"0xf9b181bf": {
|
||||||
|
"info": "GaslessTransactions",
|
||||||
|
"source": "https://github.com/tangem-developments/tangem-gasless-service",
|
||||||
|
"name": "gaslessTransaction"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
namespace = "com.tangem.domain.transaction"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(deps.kotlin.coroutines)
|
implementation(deps.kotlin.coroutines)
|
||||||
implementation(deps.arrow.core)
|
implementation(deps.arrow.core)
|
||||||
|
|
@ -42,6 +46,8 @@ dependencies {
|
||||||
implementation(projects.domain.notifications)
|
implementation(projects.domain.notifications)
|
||||||
api(projects.domain.networks)
|
api(projects.domain.networks)
|
||||||
|
|
||||||
|
testRuntimeOnly(deps.test.junit5.engine)
|
||||||
|
testRuntimeOnly(deps.test.junit5.vintage.engine)
|
||||||
testImplementation(projects.common.test)
|
testImplementation(projects.common.test)
|
||||||
testImplementation(projects.test.core)
|
testImplementation(projects.test.core)
|
||||||
testImplementation(projects.test.mock)
|
testImplementation(projects.test.mock)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ sealed class GetFeeError {
|
||||||
data object NetworkIsNotSupported : GaslessError()
|
data object NetworkIsNotSupported : GaslessError()
|
||||||
data object NoSupportedTokensFound : GaslessError()
|
data object NoSupportedTokensFound : GaslessError()
|
||||||
data object NotEnoughFunds : GaslessError()
|
data object NotEnoughFunds : GaslessError()
|
||||||
|
data object ModuleUpdateUnavailable : GaslessError()
|
||||||
data class DataError(val cause: Throwable?) : GaslessError()
|
data class DataError(val cause: Throwable?) : GaslessError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.domain.transaction
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||||
|
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
@ -57,6 +58,33 @@ interface GaslessTransactionRepository {
|
||||||
eip7702Auth: Eip7702Authorization? = null,
|
eip7702Auth: Eip7702Authorization? = null,
|
||||||
): GaslessSignedTransactionResult
|
): GaslessSignedTransactionResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a gasless BATCH transaction to the gasless service for signing and returns the signed result.
|
||||||
|
*
|
||||||
|
* Mirrors [signGaslessTransaction] but accepts multiple transactions executed in array order.
|
||||||
|
* Index 0 is the user's main transaction; subsequent entries are appended operations
|
||||||
|
* (e.g. a yield `withdraw` to cover the fee from staked balance).
|
||||||
|
*
|
||||||
|
* @param gaslessBatchTransactionData domain model containing:
|
||||||
|
* - transactions: ordered list of calls (to, value, data)
|
||||||
|
* - fee: token payment configuration
|
||||||
|
* - nonce: user's contract nonce to prevent replay attacks
|
||||||
|
* @param signature user's ECDSA signature of the batch transaction in hex format (0x...)
|
||||||
|
* @param userAddress user's Ethereum address (EOA or contract wallet)
|
||||||
|
* @param network blockchain network used to determine chainId for the request
|
||||||
|
* @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract
|
||||||
|
* @return [GaslessSignedTransactionResult] containing the fully signed transaction ready to broadcast
|
||||||
|
* @throws IllegalStateException if network is not supported or chainId cannot be determined
|
||||||
|
* @throws Exception if service returns error or network request fails
|
||||||
|
*/
|
||||||
|
suspend fun signGaslessBatchTransaction(
|
||||||
|
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||||
|
signature: String,
|
||||||
|
userAddress: String,
|
||||||
|
network: Network,
|
||||||
|
eip7702Auth: Eip7702Authorization? = null,
|
||||||
|
): GaslessSignedTransactionResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hardcoded value as baseGas
|
* Hardcoded value as baseGas
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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,
|
val nonce: BigInteger,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
|
||||||
* Core transaction data.
|
|
||||||
*
|
|
||||||
* @property to destination address
|
|
||||||
* @property value transaction value in wei (currently always 0 for gasless)
|
|
||||||
* @property data encoded transaction data (contract call)
|
|
||||||
*/
|
|
||||||
data class Transaction(
|
data class Transaction(
|
||||||
val to: String,
|
val to: String,
|
||||||
val value: BigInteger,
|
val value: BigInteger,
|
||||||
|
val gasLimit: BigInteger,
|
||||||
val data: ByteArray,
|
val data: ByteArray,
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
|
|
@ -35,6 +30,7 @@ data class GaslessTransactionData(
|
||||||
|
|
||||||
if (to != other.to) return false
|
if (to != other.to) return false
|
||||||
if (value != other.value) return false
|
if (value != other.value) return false
|
||||||
|
if (gasLimit != other.gasLimit) return false
|
||||||
if (!data.contentEquals(other.data)) return false
|
if (!data.contentEquals(other.data)) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -43,6 +39,7 @@ data class GaslessTransactionData(
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = to.hashCode()
|
var result = to.hashCode()
|
||||||
result = 31 * result + value.hashCode()
|
result = 31 * result + value.hashCode()
|
||||||
|
result = 31 * result + gasLimit.hashCode()
|
||||||
result = 31 * result + data.contentHashCode()
|
result = 31 * result + data.contentHashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,28 @@ package com.tangem.domain.transaction.models
|
||||||
|
|
||||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
data class TransactionFeeExtended(
|
data class TransactionFeeExtended(
|
||||||
val transactionFee: TransactionFee,
|
val transactionFee: TransactionFee,
|
||||||
val feeTokenId: CryptoCurrency.ID,
|
val feeTokenId: CryptoCurrency.ID,
|
||||||
|
/**
|
||||||
|
* Resolved gasless fee strategy. Non-null only for token-paid gasless fees; null for native fee.
|
||||||
|
* A null value is semantically equivalent to [GaslessFeePlan.NativePay] — consumers MUST treat them
|
||||||
|
* the same. [GaslessFeePlan.NativePay] is produced only by ResolveGaslessFeePlanUseCase.
|
||||||
|
* When it is [GaslessFeePlan.TokenPayWithYieldWithdraw], the send step builds a batch transaction.
|
||||||
|
*/
|
||||||
|
val gaslessFeePlan: GaslessFeePlan? = null,
|
||||||
|
/**
|
||||||
|
* Per-call gas limit for the user's main transaction, bound into the v2 EIP-712 hash
|
||||||
|
* ([GaslessTransactionData.Transaction.gasLimit]). Non-null only on the token-fee (gasless) path,
|
||||||
|
* where it equals the estimated execution gas of the user's transaction.
|
||||||
|
*/
|
||||||
|
val mainTransactionGasLimit: BigInteger? = null,
|
||||||
|
/**
|
||||||
|
* Per-call gas limit for the appended yield-withdraw sub-call in a batch. Non-null only when the
|
||||||
|
* fee is paid via [GaslessFeePlan.TokenPayWithYieldWithdraw]; used as the withdraw transaction's
|
||||||
|
* [GaslessTransactionData.Transaction.gasLimit].
|
||||||
|
*/
|
||||||
|
val withdrawGasLimit: BigInteger? = null,
|
||||||
)
|
)
|
||||||
|
|
@ -27,6 +27,8 @@ import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
import com.tangem.domain.transaction.error.SendTransactionError
|
import com.tangem.domain.transaction.error.SendTransactionError
|
||||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||||
|
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||||
|
import com.tangem.domain.transaction.models.GaslessFeePlan
|
||||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
|
|
@ -38,6 +40,7 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
private val cardSdkConfigRepository: CardSdkConfigRepository,
|
private val cardSdkConfigRepository: CardSdkConfigRepository,
|
||||||
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
|
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
|
||||||
|
private val isGaslessV2Enabled: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
|
|
@ -69,6 +72,12 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
/**
|
/**
|
||||||
* Prepares all necessary context for gasless transaction.
|
* Prepares all necessary context for gasless transaction.
|
||||||
* Includes: wallet manager, gasless provider, token status, nonce, transaction data.
|
* Includes: wallet manager, gasless provider, token status, nonce, transaction data.
|
||||||
|
*
|
||||||
|
* When the resolved fee plan is [GaslessFeePlan.TokenPayWithYieldWithdraw], the payload is a
|
||||||
|
* [GaslessPayload.Batch] with the user's main tx at index 0 and the yield-withdraw tx at index 1.
|
||||||
|
* [GaslessFeePlan.TokenPay] and a null plan produce a [GaslessPayload.Single] with the same
|
||||||
|
* single-transaction behavior as before. [GaslessFeePlan.NativePay] must never reach this use
|
||||||
|
* case — it is guarded in [assembleGaslessPayload].
|
||||||
*/
|
*/
|
||||||
private suspend fun prepareGaslessContext(
|
private suspend fun prepareGaslessContext(
|
||||||
userWallet: UserWallet,
|
userWallet: UserWallet,
|
||||||
|
|
@ -91,11 +100,17 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
|
|
||||||
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
|
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
|
||||||
|
|
||||||
val gaslessTransactionData = createGaslessTransactionData(
|
val mainTxGasLimit = fee.mainTransactionGasLimit
|
||||||
transactionData = transactionData,
|
?: error("Main transaction gas limit is required for a gasless (token-fee) transaction")
|
||||||
txFee = fee,
|
val mainTx = buildTransaction(transactionData, mainTxGasLimit)
|
||||||
currency = currency,
|
val feeObj = buildFee(fee, currency)
|
||||||
|
|
||||||
|
val payload = assembleGaslessPayload(
|
||||||
|
mainTx = mainTx,
|
||||||
|
feeObj = feeObj,
|
||||||
nonce = gaslessContractNonce,
|
nonce = gaslessContractNonce,
|
||||||
|
plan = fee.gaslessFeePlan,
|
||||||
|
withdrawGasLimit = fee.withdrawGasLimit,
|
||||||
)
|
)
|
||||||
|
|
||||||
val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network)
|
val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network)
|
||||||
|
|
@ -104,7 +119,7 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
walletManager = walletManager,
|
walletManager = walletManager,
|
||||||
gaslessDataProvider = gaslessDataProvider,
|
gaslessDataProvider = gaslessDataProvider,
|
||||||
currency = currency,
|
currency = currency,
|
||||||
gaslessTransactionData = gaslessTransactionData,
|
payload = payload,
|
||||||
chainId = chainId,
|
chainId = chainId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -125,17 +140,30 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
/**
|
/**
|
||||||
* Signs gasless transaction and EIP-7702 authorization.
|
* Signs gasless transaction and EIP-7702 authorization.
|
||||||
* Returns prepared signatures and authorization data.
|
* Returns prepared signatures and authorization data.
|
||||||
|
*
|
||||||
|
* EIP-712 typed data is constructed from the payload:
|
||||||
|
* - [GaslessPayload.Single] → [Eip712TypedDataBuilder.build] (single-transaction schema)
|
||||||
|
* - [GaslessPayload.Batch] → [Eip712TypedDataBuilder.buildBatch] (batch schema)
|
||||||
*/
|
*/
|
||||||
private suspend fun signGaslessTransactionByUser(
|
private suspend fun signGaslessTransactionByUser(
|
||||||
userWallet: UserWallet,
|
userWallet: UserWallet,
|
||||||
context: GaslessContext,
|
context: GaslessContext,
|
||||||
transactionData: TransactionData.Uncompiled,
|
transactionData: TransactionData.Uncompiled,
|
||||||
): SignedGaslessData {
|
): SignedGaslessData {
|
||||||
val eip712Data = Eip712TypedDataBuilder.build(
|
val eip712Data = when (val payload = context.payload) {
|
||||||
gaslessTransaction = context.gaslessTransactionData,
|
is GaslessPayload.Single -> Eip712TypedDataBuilder.build(
|
||||||
chainId = context.chainId,
|
gaslessTransaction = payload.data,
|
||||||
verifyingContract = transactionData.sourceAddress,
|
chainId = context.chainId,
|
||||||
)
|
verifyingContract = transactionData.sourceAddress,
|
||||||
|
includeGasLimit = isGaslessV2Enabled,
|
||||||
|
)
|
||||||
|
is GaslessPayload.Batch -> Eip712TypedDataBuilder.buildBatch(
|
||||||
|
gaslessBatch = payload.data,
|
||||||
|
chainId = context.chainId,
|
||||||
|
verifyingContract = transactionData.sourceAddress,
|
||||||
|
includeGasLimit = isGaslessV2Enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
|
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
|
||||||
val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider)
|
val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider)
|
||||||
|
|
@ -182,19 +210,34 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends gasless transaction to the service.
|
* Sends gasless transaction to the service.
|
||||||
|
*
|
||||||
|
* Routes to the appropriate repository call based on payload type:
|
||||||
|
* - [GaslessPayload.Single] → [GaslessTransactionRepository.signGaslessTransaction]
|
||||||
|
* - [GaslessPayload.Batch] → [GaslessTransactionRepository.signGaslessBatchTransaction]
|
||||||
|
*
|
||||||
|
* Pending-transaction tracking is always keyed on the main (user's) transaction only.
|
||||||
*/
|
*/
|
||||||
private suspend fun signAndSendTransactionOnBackend(
|
private suspend fun signAndSendTransactionOnBackend(
|
||||||
context: GaslessContext,
|
context: GaslessContext,
|
||||||
signedData: SignedGaslessData,
|
signedData: SignedGaslessData,
|
||||||
transactionData: TransactionData.Uncompiled,
|
transactionData: TransactionData.Uncompiled,
|
||||||
): String {
|
): String {
|
||||||
val txHash = gaslessTransactionRepository.signGaslessTransaction(
|
val txHash = when (val payload = context.payload) {
|
||||||
network = context.currency.network,
|
is GaslessPayload.Single -> gaslessTransactionRepository.signGaslessTransaction(
|
||||||
gaslessTransactionData = context.gaslessTransactionData,
|
network = context.currency.network,
|
||||||
signature = signedData.eip712Signature,
|
gaslessTransactionData = payload.data,
|
||||||
userAddress = transactionData.sourceAddress,
|
signature = signedData.eip712Signature,
|
||||||
eip7702Auth = signedData.eip7702Auth,
|
userAddress = transactionData.sourceAddress,
|
||||||
).txHash
|
eip7702Auth = signedData.eip7702Auth,
|
||||||
|
).txHash
|
||||||
|
is GaslessPayload.Batch -> gaslessTransactionRepository.signGaslessBatchTransaction(
|
||||||
|
network = context.currency.network,
|
||||||
|
gaslessBatchTransactionData = payload.data,
|
||||||
|
signature = signedData.eip712Signature,
|
||||||
|
userAddress = transactionData.sourceAddress,
|
||||||
|
eip7702Auth = signedData.eip7702Auth,
|
||||||
|
).txHash
|
||||||
|
}
|
||||||
|
|
||||||
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
|
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
|
||||||
transactionData = transactionData,
|
transactionData = transactionData,
|
||||||
|
|
@ -241,23 +284,10 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createGaslessTransactionData(
|
private fun buildTransaction(
|
||||||
transactionData: TransactionData.Uncompiled,
|
transactionData: TransactionData.Uncompiled,
|
||||||
txFee: TransactionFeeExtended,
|
gasLimit: BigInteger,
|
||||||
currency: CryptoCurrency,
|
): GaslessTransactionData.Transaction {
|
||||||
nonce: BigInteger,
|
|
||||||
): GaslessTransactionData {
|
|
||||||
val transaction = buildTransaction(transactionData)
|
|
||||||
val fee = buildFee(txFee, currency)
|
|
||||||
|
|
||||||
return GaslessTransactionData(
|
|
||||||
transaction = transaction,
|
|
||||||
fee = fee,
|
|
||||||
nonce = nonce,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction {
|
|
||||||
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
|
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
|
||||||
?: error("Ethereum call data is required")
|
?: error("Ethereum call data is required")
|
||||||
|
|
||||||
|
|
@ -268,6 +298,7 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
return GaslessTransactionData.Transaction(
|
return GaslessTransactionData.Transaction(
|
||||||
to = getDestinationAddress(transactionData),
|
to = getDestinationAddress(transactionData),
|
||||||
value = nativeAmount,
|
value = nativeAmount,
|
||||||
|
gasLimit = gasLimit,
|
||||||
data = callData.data,
|
data = callData.data,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -295,20 +326,28 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
private suspend fun getEIP7702DataForGasless(
|
private suspend fun getEIP7702DataForGasless(
|
||||||
gaslessDataProvider: EthereumGaslessDataProvider,
|
gaslessDataProvider: EthereumGaslessDataProvider,
|
||||||
): EIP7702AuthorizationData {
|
): EIP7702AuthorizationData {
|
||||||
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = false)) {
|
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = isGaslessV2Enabled)) {
|
||||||
is Result.Failure -> throw dataResult.error
|
is Result.Failure -> throw dataResult.error
|
||||||
is Result.Success -> dataResult.data
|
is Result.Success -> dataResult.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
|
/**
|
||||||
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
|
* Discriminated union of the gasless transaction payload to sign and send.
|
||||||
val contractAddress = txData.contractAddress
|
*
|
||||||
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
|
* [Single] carries a single-transaction payload (the pre-existing path).
|
||||||
ethereumCallData.destinationAddress
|
* [Batch] carries a batch payload where the yield-withdraw call is appended as the second
|
||||||
} else {
|
* transaction so that staked tokens are unlocked before the fee is settled.
|
||||||
contractAddress ?: error("supports only Token transaction with contract address")
|
*/
|
||||||
}
|
internal sealed interface GaslessPayload {
|
||||||
|
/** Single-transaction path — behavior is identical to the original implementation. */
|
||||||
|
data class Single(val data: GaslessTransactionData) : GaslessPayload
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch path — used when [GaslessFeePlan.TokenPayWithYieldWithdraw] is resolved.
|
||||||
|
* [data.transactions] has the user's main tx at index 0 and the withdraw tx at index 1.
|
||||||
|
*/
|
||||||
|
data class Batch(val data: GaslessBatchTransactionData) : GaslessPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -318,7 +357,7 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
val walletManager: WalletManager,
|
val walletManager: WalletManager,
|
||||||
val gaslessDataProvider: EthereumGaslessDataProvider,
|
val gaslessDataProvider: EthereumGaslessDataProvider,
|
||||||
val currency: CryptoCurrency,
|
val currency: CryptoCurrency,
|
||||||
val gaslessTransactionData: GaslessTransactionData,
|
val payload: GaslessPayload,
|
||||||
val chainId: Int,
|
val chainId: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -353,9 +392,75 @@ class CreateAndSendGaslessTransactionUseCase(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
internal companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the [GaslessPayload] from already-built domain objects and the resolved fee plan.
|
||||||
|
*
|
||||||
|
* Dispatch rules:
|
||||||
|
* - [GaslessFeePlan.TokenPayWithYieldWithdraw] → [GaslessPayload.Batch]: the yield-withdraw
|
||||||
|
* call is appended as the second transaction so that the fee token balance is topped up
|
||||||
|
* before the gasless service processes the fee.
|
||||||
|
* - [GaslessFeePlan.TokenPay] or `null` → [GaslessPayload.Single]: single-transaction path,
|
||||||
|
* identical to the original implementation. `null` is a legitimate value meaning the plan
|
||||||
|
* was not explicitly resolved.
|
||||||
|
* - [GaslessFeePlan.NativePay] → error: native-pay fees must never reach this use case
|
||||||
|
* (they are handled by the standard send path).
|
||||||
|
*/
|
||||||
|
internal fun assembleGaslessPayload(
|
||||||
|
mainTx: GaslessTransactionData.Transaction,
|
||||||
|
feeObj: GaslessTransactionData.Fee,
|
||||||
|
nonce: BigInteger,
|
||||||
|
plan: GaslessFeePlan?,
|
||||||
|
withdrawGasLimit: BigInteger?,
|
||||||
|
): GaslessPayload = when (plan) {
|
||||||
|
is GaslessFeePlan.TokenPayWithYieldWithdraw -> GaslessPayload.Batch(
|
||||||
|
GaslessBatchTransactionData(
|
||||||
|
transactions = listOf(
|
||||||
|
mainTx,
|
||||||
|
GaslessTransactionData.Transaction(
|
||||||
|
to = plan.yieldModuleAddress,
|
||||||
|
value = BigInteger.ZERO,
|
||||||
|
gasLimit = withdrawGasLimit
|
||||||
|
?: error("Withdraw gas limit is required for a yield-withdraw batch"),
|
||||||
|
data = plan.withdrawCallData.data,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
fee = feeObj,
|
||||||
|
nonce = nonce,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
is GaslessFeePlan.TokenPay, null -> GaslessPayload.Single(
|
||||||
|
GaslessTransactionData(transaction = mainTx, fee = feeObj, nonce = nonce),
|
||||||
|
)
|
||||||
|
is GaslessFeePlan.NativePay -> error("NativePay must not reach the gasless send path")
|
||||||
|
}
|
||||||
|
|
||||||
fun BigInteger.toFormattedHex(bytes: Int): String {
|
fun BigInteger.toFormattedHex(bytes: Int): String {
|
||||||
return toByteArray().normalizeByteArray(bytes).toHexString().formatHex()
|
return toByteArray().normalizeByteArray(bytes).toHexString().formatHex()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the on-chain `to` for the user's main gasless sub-call.
|
||||||
|
*
|
||||||
|
* - Yield-supply send (`EthereumYieldSupplySendCallData`, selector 0x0779afe6): `send(token, dest,
|
||||||
|
* amount)` is a method ON the user's yield module — the executor must CALL the module (it holds the
|
||||||
|
* staked funds and routes the transfer); the recipient is already encoded inside the call data.
|
||||||
|
* [TransactionData.Uncompiled.destinationAddress] is patched to the module address in
|
||||||
|
* `DefaultTransactionRepository.createTransaction`, mirroring the non-gasless send path (and the
|
||||||
|
* withdraw sub-call's `to`). Reading `ethereumCallData.destinationAddress` (the recipient) instead
|
||||||
|
* makes the executor call a plain address with the module's calldata, reverting the whole batch with
|
||||||
|
* GAS_ESTIMATION_FAILED / require(false).
|
||||||
|
* - Otherwise (e.g. ERC-20 transfer): `to` is the contract the calldata runs against
|
||||||
|
* ([TransactionData.Uncompiled.contractAddress], the token contract).
|
||||||
|
*/
|
||||||
|
internal fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
|
||||||
|
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
|
||||||
|
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
|
||||||
|
txData.destinationAddress
|
||||||
|
} else {
|
||||||
|
txData.contractAddress ?: error("supports only Token transaction with contract address")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.domain.transaction.usecase.gasless
|
package com.tangem.domain.transaction.usecase.gasless
|
||||||
|
|
||||||
import com.tangem.common.extensions.toHexString
|
import com.tangem.common.extensions.toHexString
|
||||||
|
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -26,6 +27,7 @@ object Eip712TypedDataBuilder {
|
||||||
private const val DOMAIN_NAME = "Tangem7702GaslessExecutor"
|
private const val DOMAIN_NAME = "Tangem7702GaslessExecutor"
|
||||||
private const val DOMAIN_VERSION = "1"
|
private const val DOMAIN_VERSION = "1"
|
||||||
private const val PRIMARY_TYPE = "GaslessTransaction"
|
private const val PRIMARY_TYPE = "GaslessTransaction"
|
||||||
|
private const val PRIMARY_TYPE_BATCH = "GaslessBatchTransaction"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds EIP-712 typed data JSON for gasless transaction.
|
* Builds EIP-712 typed data JSON for gasless transaction.
|
||||||
|
|
@ -35,47 +37,106 @@ object Eip712TypedDataBuilder {
|
||||||
* @param verifyingContract address of the deployed gasless executor contract
|
* @param verifyingContract address of the deployed gasless executor contract
|
||||||
* @return JSON string ready for EIP-712 signing
|
* @return JSON string ready for EIP-712 signing
|
||||||
*/
|
*/
|
||||||
fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String {
|
fun build(
|
||||||
|
gaslessTransaction: GaslessTransactionData,
|
||||||
|
chainId: Int,
|
||||||
|
verifyingContract: String,
|
||||||
|
includeGasLimit: Boolean = true,
|
||||||
|
): String {
|
||||||
val typedData = JSONObject().apply {
|
val typedData = JSONObject().apply {
|
||||||
put("types", buildTypes())
|
put("types", buildTypes(includeGasLimit))
|
||||||
put("primaryType", PRIMARY_TYPE)
|
put("primaryType", PRIMARY_TYPE)
|
||||||
put("domain", buildDomain(chainId, verifyingContract))
|
put("domain", buildDomain(chainId, verifyingContract))
|
||||||
put("message", buildMessage(gaslessTransaction))
|
put("message", buildMessage(gaslessTransaction, includeGasLimit))
|
||||||
}
|
}
|
||||||
return typedData.toString()
|
return typedData.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds EIP-712 typed data JSON for gasless batch transaction.
|
||||||
|
*
|
||||||
|
* @param gaslessBatch domain model with ordered list of transactions and fee data
|
||||||
|
* @param chainId blockchain network chain ID
|
||||||
|
* @param verifyingContract address of the deployed gasless executor contract
|
||||||
|
* @return JSON string ready for EIP-712 signing
|
||||||
|
*/
|
||||||
|
fun buildBatch(
|
||||||
|
gaslessBatch: GaslessBatchTransactionData,
|
||||||
|
chainId: Int,
|
||||||
|
verifyingContract: String,
|
||||||
|
includeGasLimit: Boolean = true,
|
||||||
|
): String {
|
||||||
|
require(
|
||||||
|
gaslessBatch.transactions.isNotEmpty(),
|
||||||
|
) { "GaslessBatchTransaction must contain at least one transaction" }
|
||||||
|
val typedData = JSONObject().apply {
|
||||||
|
put("types", buildBatchTypes(includeGasLimit))
|
||||||
|
put("primaryType", PRIMARY_TYPE_BATCH)
|
||||||
|
put("domain", buildDomain(chainId, verifyingContract))
|
||||||
|
put("message", buildBatchMessage(gaslessBatch, includeGasLimit))
|
||||||
|
}
|
||||||
|
return typedData.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the type definitions for all structures in the batch variant.
|
||||||
|
* Uses `Transaction[]` for the ordered transactions array.
|
||||||
|
*/
|
||||||
|
private fun buildBatchTypes(includeGasLimit: Boolean): JSONObject {
|
||||||
|
return JSONObject().apply {
|
||||||
|
put("EIP712Domain", buildEip712DomainTypeProperties())
|
||||||
|
put("Transaction", buildTransactionTypeProperties(includeGasLimit))
|
||||||
|
put("Fee", buildFeeTypeProperties())
|
||||||
|
put("GaslessBatchTransaction", buildGaslessBatchTransactionTypeProperties())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGaslessBatchTransactionTypeProperties(): JSONArray {
|
||||||
|
return JSONArray().apply {
|
||||||
|
put(typeProperty("transactions", "Transaction[]"))
|
||||||
|
put(typeProperty("fee", "Fee"))
|
||||||
|
put(typeProperty("nonce", "uint256"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the message data from gasless batch transaction.
|
||||||
|
*/
|
||||||
|
private fun buildBatchMessage(gaslessBatch: GaslessBatchTransactionData, includeGasLimit: Boolean): JSONObject {
|
||||||
|
return JSONObject().apply {
|
||||||
|
put("transactions", buildTransactionsArray(gaslessBatch.transactions, includeGasLimit))
|
||||||
|
put("fee", buildFeeMessage(gaslessBatch.fee))
|
||||||
|
put("nonce", gaslessBatch.nonce.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTransactionsArray(
|
||||||
|
transactions: List<GaslessTransactionData.Transaction>,
|
||||||
|
includeGasLimit: Boolean,
|
||||||
|
): JSONArray {
|
||||||
|
return JSONArray().apply {
|
||||||
|
transactions.forEach { tx -> put(buildTransactionMessage(tx, includeGasLimit)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the type definitions for all structures.
|
* Builds the type definitions for all structures.
|
||||||
* This schema is fixed and defines the structure of the data being signed.
|
* This schema is fixed and defines the structure of the data being signed.
|
||||||
*/
|
*/
|
||||||
@Suppress("NestedScopeFunctions")
|
private fun buildTypes(includeGasLimit: Boolean): JSONObject {
|
||||||
private fun buildTypes(): JSONObject {
|
|
||||||
return JSONObject().apply {
|
return JSONObject().apply {
|
||||||
put("EIP712Domain", JSONArray().apply {
|
put("EIP712Domain", buildEip712DomainTypeProperties())
|
||||||
put(typeProperty("name", "string"))
|
put("Transaction", buildTransactionTypeProperties(includeGasLimit))
|
||||||
put(typeProperty("version", "string"))
|
put("Fee", buildFeeTypeProperties())
|
||||||
put(typeProperty("chainId", "uint256"))
|
put("GaslessTransaction", buildGaslessTransactionTypeProperties())
|
||||||
put(typeProperty("verifyingContract", "address"))
|
}
|
||||||
})
|
}
|
||||||
put("Transaction", JSONArray().apply {
|
|
||||||
put(typeProperty("to", "address"))
|
private fun buildGaslessTransactionTypeProperties(): JSONArray {
|
||||||
put(typeProperty("value", "uint256"))
|
return JSONArray().apply {
|
||||||
put(typeProperty("data", "bytes"))
|
put(typeProperty("transaction", "Transaction"))
|
||||||
})
|
put(typeProperty("fee", "Fee"))
|
||||||
put("Fee", JSONArray().apply {
|
put(typeProperty("nonce", "uint256"))
|
||||||
put(typeProperty("feeToken", "address"))
|
|
||||||
put(typeProperty("maxTokenFee", "uint256"))
|
|
||||||
put(typeProperty("coinPriceInToken", "uint256"))
|
|
||||||
put(typeProperty("feeTransferGasLimit", "uint256"))
|
|
||||||
put(typeProperty("baseGas", "uint256"))
|
|
||||||
put(typeProperty("feeReceiver", "address"))
|
|
||||||
})
|
|
||||||
put("GaslessTransaction", JSONArray().apply {
|
|
||||||
put(typeProperty("transaction", "Transaction"))
|
|
||||||
put(typeProperty("fee", "Fee"))
|
|
||||||
put(typeProperty("nonce", "uint256"))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,23 +165,71 @@ object Eip712TypedDataBuilder {
|
||||||
/**
|
/**
|
||||||
* Builds the message data from gasless transaction.
|
* Builds the message data from gasless transaction.
|
||||||
*/
|
*/
|
||||||
@Suppress("NestedScopeFunctions")
|
private fun buildMessage(gaslessTransaction: GaslessTransactionData, includeGasLimit: Boolean): JSONObject {
|
||||||
private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject {
|
|
||||||
return JSONObject().apply {
|
return JSONObject().apply {
|
||||||
put("transaction", JSONObject().apply {
|
put("transaction", buildTransactionMessage(gaslessTransaction.transaction, includeGasLimit))
|
||||||
put("to", gaslessTransaction.transaction.to)
|
put("fee", buildFeeMessage(gaslessTransaction.fee))
|
||||||
put("value", gaslessTransaction.transaction.value.toString())
|
|
||||||
put("data", gaslessTransaction.transaction.data.toHexString())
|
|
||||||
})
|
|
||||||
put("fee", JSONObject().apply {
|
|
||||||
put("feeToken", gaslessTransaction.fee.feeToken)
|
|
||||||
put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString())
|
|
||||||
put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString())
|
|
||||||
put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString())
|
|
||||||
put("baseGas", gaslessTransaction.fee.baseGas.toString())
|
|
||||||
put("feeReceiver", gaslessTransaction.fee.feeReceiver)
|
|
||||||
})
|
|
||||||
put("nonce", gaslessTransaction.nonce.toString())
|
put("nonce", gaslessTransaction.nonce.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildTransactionMessage(
|
||||||
|
transaction: GaslessTransactionData.Transaction,
|
||||||
|
includeGasLimit: Boolean,
|
||||||
|
): JSONObject {
|
||||||
|
return JSONObject().apply {
|
||||||
|
put("to", transaction.to)
|
||||||
|
put("value", transaction.value.toString())
|
||||||
|
if (includeGasLimit) put("gasLimit", transaction.gasLimit.toString())
|
||||||
|
put("data", transaction.data.toHexString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Shared type schema helpers
|
||||||
|
|
||||||
|
private fun buildEip712DomainTypeProperties(): JSONArray {
|
||||||
|
return JSONArray().apply {
|
||||||
|
put(typeProperty("name", "string"))
|
||||||
|
put(typeProperty("version", "string"))
|
||||||
|
put(typeProperty("chainId", "uint256"))
|
||||||
|
put(typeProperty("verifyingContract", "address"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTransactionTypeProperties(includeGasLimit: Boolean): JSONArray {
|
||||||
|
return JSONArray().apply {
|
||||||
|
put(typeProperty("to", "address"))
|
||||||
|
put(typeProperty("value", "uint256"))
|
||||||
|
if (includeGasLimit) put(typeProperty("gasLimit", "uint256"))
|
||||||
|
put(typeProperty("data", "bytes"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFeeTypeProperties(): JSONArray {
|
||||||
|
return JSONArray().apply {
|
||||||
|
put(typeProperty("feeToken", "address"))
|
||||||
|
put(typeProperty("maxTokenFee", "uint256"))
|
||||||
|
put(typeProperty("coinPriceInToken", "uint256"))
|
||||||
|
put(typeProperty("feeTransferGasLimit", "uint256"))
|
||||||
|
put(typeProperty("baseGas", "uint256"))
|
||||||
|
put(typeProperty("feeReceiver", "address"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Shared message helpers
|
||||||
|
|
||||||
|
private fun buildFeeMessage(fee: GaslessTransactionData.Fee): JSONObject {
|
||||||
|
return JSONObject().apply {
|
||||||
|
put("feeToken", fee.feeToken)
|
||||||
|
put("maxTokenFee", fee.maxTokenFee.toString())
|
||||||
|
put("coinPriceInToken", fee.coinPriceInToken.toString())
|
||||||
|
put("feeTransferGasLimit", fee.feeTransferGasLimit.toString())
|
||||||
|
put("baseGas", fee.baseGas.toString())
|
||||||
|
put("feeReceiver", fee.feeReceiver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
}
|
}
|
||||||
|
|
@ -18,12 +18,14 @@ import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import com.tangem.domain.transaction.error.GetFeeError
|
import com.tangem.domain.transaction.error.GetFeeError
|
||||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||||
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
|
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
|
||||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
|
import com.tangem.utils.extensions.isZero
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
|
|
@ -31,6 +33,7 @@ class EstimateFeeForGaslessTxUseCase(
|
||||||
private val walletManagersFacade: WalletManagersFacade,
|
private val walletManagersFacade: WalletManagersFacade,
|
||||||
private val demoConfig: DemoConfig,
|
private val demoConfig: DemoConfig,
|
||||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
private val gaslessYieldRepository: GaslessYieldRepository,
|
||||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
private val estimateFeeUseCase: EstimateFeeUseCase,
|
private val estimateFeeUseCase: EstimateFeeUseCase,
|
||||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
|
@ -40,6 +43,7 @@ class EstimateFeeForGaslessTxUseCase(
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
demoConfig = demoConfig,
|
demoConfig = demoConfig,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
|
|
@ -153,11 +157,11 @@ class EstimateFeeForGaslessTxUseCase(
|
||||||
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
|
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
|
||||||
network = nativeCurrencyStatus.currency.network,
|
network = nativeCurrencyStatus.currency.network,
|
||||||
).mapNotNull { currency ->
|
).mapNotNull { currency ->
|
||||||
(currency as? CryptoCurrency.Token)?.contractAddress
|
(currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
|
||||||
}.toSet()
|
}.toSet()
|
||||||
|
|
||||||
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
|
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
|
||||||
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
|
.filterNot { it.value.amount?.isZero() == true || it.currency !is CryptoCurrency.Token }
|
||||||
.sortedByDescending { it.value.amount }
|
.sortedByDescending { it.value.amount }
|
||||||
.filter { status ->
|
.filter { status ->
|
||||||
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
|
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import com.tangem.domain.transaction.error.GetFeeError
|
import com.tangem.domain.transaction.error.GetFeeError
|
||||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||||
|
|
@ -22,18 +23,22 @@ import com.tangem.domain.transaction.raiseIllegalStateError
|
||||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
class EstimateFeeForTokenUseCase(
|
class EstimateFeeForTokenUseCase(
|
||||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
private val gaslessYieldRepository: GaslessYieldRepository,
|
||||||
private val walletManagersFacade: WalletManagersFacade,
|
private val walletManagersFacade: WalletManagersFacade,
|
||||||
private val demoConfig: DemoConfig,
|
private val demoConfig: DemoConfig,
|
||||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
private val isYieldWithdrawEnabled: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val tokenFeeCalculator = TokenFeeCalculator(
|
private val tokenFeeCalculator = TokenFeeCalculator(
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
demoConfig = demoConfig,
|
demoConfig = demoConfig,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
|
|
@ -70,11 +75,15 @@ class EstimateFeeForTokenUseCase(
|
||||||
|
|
||||||
val walletManager = prepareWalletManager(userWallet, token.network)
|
val walletManager = prepareWalletManager(userWallet, token.network)
|
||||||
|
|
||||||
|
val isYieldActive = isYieldWithdrawEnabled &&
|
||||||
|
feeTokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
|
||||||
|
|
||||||
tokenFeeCalculator.calculateTokenFee(
|
tokenFeeCalculator.calculateTokenFee(
|
||||||
walletManager = walletManager,
|
walletManager = walletManager,
|
||||||
tokenForPayFeeStatus = feeTokenCurrencyStatus,
|
tokenForPayFeeStatus = feeTokenCurrencyStatus,
|
||||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||||
initialFee = initialFeeEth,
|
initialFee = initialFeeEth,
|
||||||
|
isYieldActive = isYieldActive,
|
||||||
).bind()
|
).bind()
|
||||||
},
|
},
|
||||||
catch = {
|
catch = {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class GetAvailableFeeTokensUseCase(
|
||||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
private val isYieldWithdrawEnabled: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -69,7 +70,7 @@ class GetAvailableFeeTokensUseCase(
|
||||||
}.toSet()
|
}.toSet()
|
||||||
return userCurrenciesStatuses
|
return userCurrenciesStatuses
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { it.value.yieldSupplyStatus == null }
|
.filter { isEligibleFeeToken(it, isYieldWithdrawEnabled) }
|
||||||
.filter { it.currency.network.id == network.id }
|
.filter { it.currency.network.id == network.id }
|
||||||
.filter { currencyStatus ->
|
.filter { currencyStatus ->
|
||||||
val token = currencyStatus.currency
|
val token = currencyStatus.currency
|
||||||
|
|
@ -77,4 +78,12 @@ class GetAvailableFeeTokensUseCase(
|
||||||
}
|
}
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal companion object {
|
||||||
|
|
||||||
|
internal fun isEligibleFeeToken(status: CryptoCurrencyStatus, isYieldWithdrawEnabled: Boolean): Boolean {
|
||||||
|
val yieldSupplyStatus = status.value.yieldSupplyStatus ?: return true
|
||||||
|
return isYieldWithdrawEnabled && yieldSupplyStatus.isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import arrow.core.raise.Raise
|
||||||
import arrow.core.raise.catch
|
import arrow.core.raise.catch
|
||||||
import arrow.core.raise.either
|
import arrow.core.raise.either
|
||||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||||
|
import com.tangem.blockchain.common.AmountType
|
||||||
import com.tangem.blockchain.common.TransactionData
|
import com.tangem.blockchain.common.TransactionData
|
||||||
import com.tangem.blockchain.common.transaction.Fee
|
import com.tangem.blockchain.common.transaction.Fee
|
||||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||||
|
|
@ -19,6 +20,7 @@ import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import com.tangem.domain.transaction.error.GetFeeError
|
import com.tangem.domain.transaction.error.GetFeeError
|
||||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||||
|
|
@ -32,15 +34,19 @@ class GetFeeForGaslessUseCase(
|
||||||
private val walletManagersFacade: WalletManagersFacade,
|
private val walletManagersFacade: WalletManagersFacade,
|
||||||
private val demoConfig: DemoConfig,
|
private val demoConfig: DemoConfig,
|
||||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
private val gaslessYieldRepository: GaslessYieldRepository,
|
||||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
private val getFeeUseCase: GetFeeUseCase,
|
private val getFeeUseCase: GetFeeUseCase,
|
||||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
|
||||||
|
private val isYieldWithdrawEnabled: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val tokenFeeCalculator = TokenFeeCalculator(
|
private val tokenFeeCalculator = TokenFeeCalculator(
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
demoConfig = demoConfig,
|
demoConfig = demoConfig,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
|
|
@ -80,11 +86,13 @@ class GetFeeForGaslessUseCase(
|
||||||
).bind()
|
).bind()
|
||||||
|
|
||||||
selectFeePaymentStrategy(
|
selectFeePaymentStrategy(
|
||||||
|
userWallet = userWallet,
|
||||||
accountStatusList = accountStatusList,
|
accountStatusList = accountStatusList,
|
||||||
walletManager = walletManager,
|
walletManager = walletManager,
|
||||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||||
network = network,
|
network = network,
|
||||||
initialFee = initialFee,
|
initialFee = initialFee,
|
||||||
|
transactionData = transactionData,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
catch = {
|
catch = {
|
||||||
|
|
@ -108,12 +116,15 @@ class GetFeeForGaslessUseCase(
|
||||||
return ethereumWalletManager
|
return ethereumWalletManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
|
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
|
||||||
|
userWallet: UserWallet,
|
||||||
accountStatusList: AccountStatusList,
|
accountStatusList: AccountStatusList,
|
||||||
walletManager: EthereumWalletManager,
|
walletManager: EthereumWalletManager,
|
||||||
nativeCurrencyStatus: CryptoCurrencyStatus,
|
nativeCurrencyStatus: CryptoCurrencyStatus,
|
||||||
network: Network,
|
network: Network,
|
||||||
initialFee: TransactionFee,
|
initialFee: TransactionFee,
|
||||||
|
transactionData: TransactionData,
|
||||||
): TransactionFeeExtended {
|
): TransactionFeeExtended {
|
||||||
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
|
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
|
||||||
|
|
||||||
|
|
@ -128,10 +139,12 @@ class GetFeeForGaslessUseCase(
|
||||||
nativeCoinSelectedResult
|
nativeCoinSelectedResult
|
||||||
} else {
|
} else {
|
||||||
findTokensToPayFee(
|
findTokensToPayFee(
|
||||||
|
userWallet = userWallet,
|
||||||
walletManager = walletManager,
|
walletManager = walletManager,
|
||||||
initialTxFee = initialFee,
|
initialTxFee = initialFee,
|
||||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||||
networkCurrenciesStatuses = networkCurrenciesStatuses,
|
networkCurrenciesStatuses = networkCurrenciesStatuses,
|
||||||
|
transactionData = transactionData,
|
||||||
).getOrElse { error ->
|
).getOrElse { error ->
|
||||||
when (error) {
|
when (error) {
|
||||||
GaslessError.NotEnoughFunds -> nativeCoinSelectedResult
|
GaslessError.NotEnoughFunds -> nativeCoinSelectedResult
|
||||||
|
|
@ -141,12 +154,14 @@ class GetFeeForGaslessUseCase(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("NullableToStringCall")
|
@Suppress("NullableToStringCall", "LongParameterList")
|
||||||
private suspend fun findTokensToPayFee(
|
private suspend fun findTokensToPayFee(
|
||||||
|
userWallet: UserWallet,
|
||||||
walletManager: EthereumWalletManager,
|
walletManager: EthereumWalletManager,
|
||||||
initialTxFee: TransactionFee,
|
initialTxFee: TransactionFee,
|
||||||
nativeCurrencyStatus: CryptoCurrencyStatus,
|
nativeCurrencyStatus: CryptoCurrencyStatus,
|
||||||
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
|
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
|
||||||
|
transactionData: TransactionData,
|
||||||
): Either<GetFeeError, TransactionFeeExtended> = either {
|
): Either<GetFeeError, TransactionFeeExtended> = either {
|
||||||
val initialFee = initialTxFee.normal as? Fee.Ethereum
|
val initialFee = initialTxFee.normal as? Fee.Ethereum
|
||||||
?: raiseIllegalStateError(
|
?: raiseIllegalStateError(
|
||||||
|
|
@ -156,29 +171,109 @@ class GetFeeForGaslessUseCase(
|
||||||
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
|
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
|
||||||
network = nativeCurrencyStatus.currency.network,
|
network = nativeCurrencyStatus.currency.network,
|
||||||
).mapNotNull { currency ->
|
).mapNotNull { currency ->
|
||||||
(currency as? CryptoCurrency.Token)?.contractAddress
|
(currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
|
||||||
}.toSet()
|
}.toSet()
|
||||||
|
|
||||||
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
|
|
||||||
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
|
|
||||||
.sortedByDescending { it.value.amount }
|
|
||||||
.filter { status ->
|
|
||||||
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
|
|
||||||
token.contractAddress.lowercase() in supportedGaslessTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects token with highest balance to maximize chances of successful fee payment.
|
* Yield-aware candidate selection:
|
||||||
* Returns null if no suitable tokens found.
|
* a token is eligible if it is a supported gasless token AND
|
||||||
|
* (total balance > 0 OR has an active yield position).
|
||||||
|
* Sorted by total balance descending to maximise chances of covering the fee. For a yield token
|
||||||
|
* value.amount is already effectiveBalance (liquid EOA + effectiveProtocolBalance), so it must NOT
|
||||||
|
* be summed with effectiveProtocolBalance again — that would double-count the module portion.
|
||||||
*/
|
*/
|
||||||
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
|
val candidates = networkCurrenciesStatuses
|
||||||
?: raise(GaslessError.NoSupportedTokensFound)
|
.asSequence()
|
||||||
|
.filter { it.currency is CryptoCurrency.Token }
|
||||||
|
.filter { (it.currency as CryptoCurrency.Token).contractAddress.lowercase() in supportedGaslessTokens }
|
||||||
|
.filter { status ->
|
||||||
|
val total = status.value.amount ?: BigDecimal.ZERO
|
||||||
|
total > BigDecimal.ZERO || isYieldWithdrawEnabled && status.value.yieldSupplyStatus?.isActive == true
|
||||||
|
}
|
||||||
|
.sortedByDescending { status -> status.value.amount ?: BigDecimal.ZERO }
|
||||||
|
|
||||||
return tokenFeeCalculator.calculateTokenFee(
|
val tokenForPayFeeStatus = candidates.firstOrNull() ?: raise(GaslessError.NoSupportedTokensFound)
|
||||||
|
|
||||||
|
val isYieldActive = isYieldWithdrawEnabled && tokenForPayFeeStatus.value.yieldSupplyStatus?.isActive == true
|
||||||
|
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
|
||||||
walletManager = walletManager,
|
walletManager = walletManager,
|
||||||
tokenForPayFeeStatus = tokenForPayFeeStatus,
|
tokenForPayFeeStatus = tokenForPayFeeStatus,
|
||||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||||
initialFee = initialFee,
|
initialFee = initialFee,
|
||||||
|
isYieldActive = isYieldActive,
|
||||||
|
userWallet = userWallet,
|
||||||
|
).bind()
|
||||||
|
|
||||||
|
attachGaslessFeePlan(
|
||||||
|
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
|
||||||
|
userWallet = userWallet,
|
||||||
|
tokenStatus = tokenForPayFeeStatus,
|
||||||
|
tokenFeeExtended = tokenFeeExtended,
|
||||||
|
transactionData = transactionData,
|
||||||
|
isYieldActive = isYieldActive,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the [com.tangem.domain.transaction.models.GaslessFeePlan] for [tokenStatus] paying the gasless
|
||||||
|
* fee and attaches it to [tokenFeeExtended]. Shared by the auto path ([GetFeeForGaslessUseCase]) and the
|
||||||
|
* manual fee-token selection path ([GetFeeForTokenUseCase]) so both produce identical plans.
|
||||||
|
*/
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
internal suspend fun Raise<GetFeeError>.attachGaslessFeePlan(
|
||||||
|
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
|
||||||
|
userWallet: UserWallet,
|
||||||
|
tokenStatus: CryptoCurrencyStatus,
|
||||||
|
tokenFeeExtended: TransactionFeeExtended,
|
||||||
|
transactionData: TransactionData,
|
||||||
|
isYieldActive: Boolean,
|
||||||
|
): TransactionFeeExtended {
|
||||||
|
val feeInTokenCurrency = tokenFeeExtended.transactionFee.normal as? Fee.Ethereum.TokenCurrency
|
||||||
|
?: raiseIllegalStateError("gasless token fee must be Fee.Ethereum.TokenCurrency")
|
||||||
|
val feeTokenContract = (tokenStatus.currency as? CryptoCurrency.Token)?.contractAddress
|
||||||
|
?: raiseIllegalStateError("gasless fee currency must be a token")
|
||||||
|
|
||||||
|
val plan = resolveGaslessFeePlanUseCase(
|
||||||
|
userWallet = userWallet,
|
||||||
|
tokenStatus = tokenStatus,
|
||||||
|
tokenFee = feeInTokenCurrency,
|
||||||
|
isYieldActive = isYieldActive,
|
||||||
|
sendAmountInFeeToken = computeSendAmountInFeeToken(transactionData, feeTokenContract),
|
||||||
|
).bind()
|
||||||
|
|
||||||
|
return tokenFeeExtended.copy(gaslessFeePlan = plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes how much of the fee token is also being spent in the main transaction body.
|
||||||
|
*
|
||||||
|
* Gasless token-fee transactions MUST supply uncompiled data (the resolver needs the raw amount to
|
||||||
|
* account for it in the required-balance check). A compiled tx or a null sent amount on the
|
||||||
|
* matching-token path are both programmer errors, so they raise loudly instead of silently
|
||||||
|
* under-accounting as ZERO.
|
||||||
|
*
|
||||||
|
* @param transactionData the raw transaction data passed into [GetFeeForGaslessUseCase].
|
||||||
|
* @param feeTokenContract the contract address of the token selected to pay the gasless fee.
|
||||||
|
* @return the sent amount when [feeTokenContract] matches the sent-token contract,
|
||||||
|
* or [BigDecimal.ZERO] when a different token is being sent.
|
||||||
|
*/
|
||||||
|
internal fun Raise<GetFeeError>.computeSendAmountInFeeToken(
|
||||||
|
transactionData: TransactionData,
|
||||||
|
feeTokenContract: String,
|
||||||
|
): BigDecimal {
|
||||||
|
// Gasless token-fee requires uncompiled tx data (mirrors CreateAndSendGaslessTransactionUseCase).
|
||||||
|
val uncompiled = transactionData as? TransactionData.Uncompiled
|
||||||
|
?: raiseIllegalStateError("gasless token fee requires uncompiled transaction data")
|
||||||
|
val sentTokenContract = when (val type = uncompiled.amount.type) {
|
||||||
|
is AmountType.Token -> type.token.contractAddress
|
||||||
|
is AmountType.TokenYieldSupply -> type.token.contractAddress
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return if (sentTokenContract != null && sentTokenContract.equals(feeTokenContract, ignoreCase = true)) {
|
||||||
|
uncompiled.amount.value
|
||||||
|
?: raiseIllegalStateError("sent amount is null while paying the gasless fee in the sent token")
|
||||||
|
} else {
|
||||||
|
BigDecimal.ZERO
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -17,24 +17,30 @@ import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import com.tangem.domain.transaction.error.GetFeeError
|
import com.tangem.domain.transaction.error.GetFeeError
|
||||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||||
import com.tangem.domain.transaction.raiseIllegalStateError
|
import com.tangem.domain.transaction.raiseIllegalStateError
|
||||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
class GetFeeForTokenUseCase(
|
class GetFeeForTokenUseCase(
|
||||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
|
private val gaslessYieldRepository: GaslessYieldRepository,
|
||||||
private val walletManagersFacade: WalletManagersFacade,
|
private val walletManagersFacade: WalletManagersFacade,
|
||||||
private val demoConfig: DemoConfig,
|
private val demoConfig: DemoConfig,
|
||||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
private val currencyChecksRepository: CurrencyChecksRepository,
|
private val currencyChecksRepository: CurrencyChecksRepository,
|
||||||
|
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
|
||||||
|
private val isYieldWithdrawEnabled: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val tokenFeeCalculator = TokenFeeCalculator(
|
private val tokenFeeCalculator = TokenFeeCalculator(
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
demoConfig = demoConfig,
|
demoConfig = demoConfig,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
|
|
@ -74,12 +80,30 @@ class GetFeeForTokenUseCase(
|
||||||
raiseIllegalStateError("Token currency not found for network ${token.network.id}")
|
raiseIllegalStateError("Token currency not found for network ${token.network.id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenFeeCalculator.calculateTokenFee(
|
val isYieldActive = isYieldWithdrawEnabled &&
|
||||||
|
tokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
|
||||||
|
|
||||||
|
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
|
||||||
walletManager = walletManager,
|
walletManager = walletManager,
|
||||||
tokenForPayFeeStatus = tokenCurrencyStatus,
|
tokenForPayFeeStatus = tokenCurrencyStatus,
|
||||||
nativeCurrencyStatus = nativeCurrencyStatus,
|
nativeCurrencyStatus = nativeCurrencyStatus,
|
||||||
initialFee = initialFeeEth,
|
initialFee = initialFeeEth,
|
||||||
|
isYieldActive = isYieldActive,
|
||||||
|
userWallet = userWallet,
|
||||||
).bind()
|
).bind()
|
||||||
|
|
||||||
|
if (isYieldActive) {
|
||||||
|
attachGaslessFeePlan(
|
||||||
|
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
|
||||||
|
userWallet = userWallet,
|
||||||
|
tokenStatus = tokenCurrencyStatus,
|
||||||
|
tokenFeeExtended = tokenFeeExtended,
|
||||||
|
transactionData = transactionData,
|
||||||
|
isYieldActive = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tokenFeeExtended
|
||||||
|
}
|
||||||
},
|
},
|
||||||
catch = {
|
catch = {
|
||||||
raise(GaslessError.DataError(it))
|
raise(GaslessError.DataError(it))
|
||||||
|
|
|
||||||
|
|
@ -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.EthereumWalletManager
|
||||||
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
|
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
|
||||||
import com.tangem.blockchain.common.Amount
|
import com.tangem.blockchain.common.Amount
|
||||||
|
import com.tangem.blockchain.common.AmountType
|
||||||
import com.tangem.blockchain.common.BlockchainSdkError
|
import com.tangem.blockchain.common.BlockchainSdkError
|
||||||
import com.tangem.blockchain.common.Token
|
import com.tangem.blockchain.common.Token
|
||||||
import com.tangem.blockchain.common.TransactionData
|
import com.tangem.blockchain.common.TransactionData
|
||||||
import com.tangem.blockchain.common.transaction.Fee
|
import com.tangem.blockchain.common.transaction.Fee
|
||||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||||
import com.tangem.blockchain.extensions.Result
|
import com.tangem.blockchain.extensions.Result
|
||||||
|
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
|
||||||
|
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
|
||||||
import com.tangem.domain.demo.DemoTransactionSender
|
import com.tangem.domain.demo.DemoTransactionSender
|
||||||
import com.tangem.domain.demo.models.DemoConfig
|
import com.tangem.domain.demo.models.DemoConfig
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
|
|
@ -19,6 +22,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import com.tangem.domain.transaction.error.GetFeeError
|
import com.tangem.domain.transaction.error.GetFeeError
|
||||||
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
|
||||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||||
|
|
@ -34,6 +38,7 @@ internal class TokenFeeCalculator(
|
||||||
private val walletManagersFacade: WalletManagersFacade,
|
private val walletManagersFacade: WalletManagersFacade,
|
||||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||||
private val demoConfig: DemoConfig,
|
private val demoConfig: DemoConfig,
|
||||||
|
private val gaslessYieldRepository: GaslessYieldRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun calculateInitialFee(
|
suspend fun calculateInitialFee(
|
||||||
|
|
@ -90,16 +95,19 @@ internal class TokenFeeCalculator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
@Suppress("LongMethod", "CyclomaticComplexity")
|
||||||
suspend fun calculateTokenFee(
|
suspend fun calculateTokenFee(
|
||||||
walletManager: EthereumWalletManager,
|
walletManager: EthereumWalletManager,
|
||||||
tokenForPayFeeStatus: CryptoCurrencyStatus,
|
tokenForPayFeeStatus: CryptoCurrencyStatus,
|
||||||
nativeCurrencyStatus: CryptoCurrencyStatus,
|
nativeCurrencyStatus: CryptoCurrencyStatus,
|
||||||
initialFee: Fee.Ethereum,
|
initialFee: Fee.Ethereum,
|
||||||
|
isYieldActive: Boolean = false,
|
||||||
|
userWallet: UserWallet? = null,
|
||||||
): Either<GetFeeError, TransactionFeeExtended> {
|
): Either<GetFeeError, TransactionFeeExtended> {
|
||||||
return either {
|
return either {
|
||||||
// fast finish to skip calculations if no funds in token
|
// fast finish to skip calculations if no funds in token.
|
||||||
if (tokenForPayFeeStatus.value.amount?.isZero() == true) {
|
// Skipped on the yield path: a zero plain balance is expected — it will be topped up from yield.
|
||||||
|
if (!isYieldActive && tokenForPayFeeStatus.value.amount?.isZero() == true) {
|
||||||
raise(GaslessError.NotEnoughFunds)
|
raise(GaslessError.NotEnoughFunds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,23 +128,16 @@ internal class TokenFeeCalculator(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val feeTransferGasLimit = when (feeTransferGasLimitResult) {
|
val feeTransferGasLimit = resolveFeeTransferGasLimit(feeTransferGasLimitResult, isYieldActive)
|
||||||
is Result.Success -> feeTransferGasLimitResult.data
|
|
||||||
is Result.Failure -> {
|
|
||||||
// If there is a dust on the balance, the gas limit estimation will fail with code
|
|
||||||
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
|
|
||||||
val cause = feeTransferGasLimitResult.error.cause
|
|
||||||
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
|
|
||||||
raise(GaslessError.NotEnoughFunds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
|
|
||||||
}
|
|
||||||
}.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
|
|
||||||
|
|
||||||
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
|
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
|
||||||
|
|
||||||
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas
|
val withdrawGas = if (isYieldActive) {
|
||||||
|
estimateWithdrawGasLimit(userWallet, walletManager, tokenForPayFee)
|
||||||
|
} else {
|
||||||
|
BigInteger.ZERO
|
||||||
|
}
|
||||||
|
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + withdrawGas
|
||||||
|
|
||||||
val maxFeePerGas = when (initialFee) {
|
val maxFeePerGas = when (initialFee) {
|
||||||
is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas
|
is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas
|
||||||
|
|
@ -170,7 +171,8 @@ internal class TokenFeeCalculator(
|
||||||
)
|
)
|
||||||
|
|
||||||
val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO
|
val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO
|
||||||
if (tokenBalance < feeInTokenCurrency) {
|
// Skipped on the yield path: ResolveGaslessFeePlanUseCase decides plain-vs-yield coverage.
|
||||||
|
if (!isYieldActive && tokenBalance < feeInTokenCurrency) {
|
||||||
raise(GaslessError.NotEnoughFunds)
|
raise(GaslessError.NotEnoughFunds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,10 +188,97 @@ internal class TokenFeeCalculator(
|
||||||
TransactionFeeExtended(
|
TransactionFeeExtended(
|
||||||
transactionFee = TransactionFee.Single(normal = fee),
|
transactionFee = TransactionFee.Single(normal = fee),
|
||||||
feeTokenId = tokenForPayFee.id,
|
feeTokenId = tokenForPayFee.id,
|
||||||
|
// Per-call gas limits for the v2 gasless meta-tx (bound into the EIP-712 hash).
|
||||||
|
// Main = the user's transaction execution gas; withdraw = the appended yield-withdraw
|
||||||
|
// sub-call gas, present only on the yield path where a batch is built.
|
||||||
|
mainTransactionGasLimit = initialFee.gasLimit,
|
||||||
|
withdrawGasLimit = withdrawGas.takeIf { isYieldActive },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the fee-transfer gas limit from the on-chain estimation result.
|
||||||
|
*
|
||||||
|
* On the yield path ([isYieldActive] = true), when the estimation reverts with
|
||||||
|
* [BlockchainSdkError.Ethereum.InsufficientFundsForOperation] (expected for a zero plain balance),
|
||||||
|
* falls back to [FALLBACK_FEE_TRANSFER_GAS_LIMIT] instead of raising [GaslessError.NotEnoughFunds].
|
||||||
|
* All other failures propagate as [GaslessError.DataError] on both paths.
|
||||||
|
*/
|
||||||
|
private fun Raise<GetFeeError>.resolveFeeTransferGasLimit(
|
||||||
|
feeTransferGasLimitResult: Result<BigInteger>,
|
||||||
|
isYieldActive: Boolean,
|
||||||
|
): BigInteger {
|
||||||
|
val rawFeeTransferGasLimit: BigInteger = when (feeTransferGasLimitResult) {
|
||||||
|
is Result.Success -> feeTransferGasLimitResult.data
|
||||||
|
is Result.Failure -> {
|
||||||
|
// If there is a dust on the balance, the gas limit estimation will fail with code
|
||||||
|
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
|
||||||
|
val cause = feeTransferGasLimitResult.error.cause
|
||||||
|
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
|
||||||
|
if (isYieldActive) {
|
||||||
|
FALLBACK_FEE_TRANSFER_GAS_LIMIT
|
||||||
|
} else {
|
||||||
|
raise(GaslessError.NotEnoughFunds)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rawFeeTransferGasLimit.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("SwallowedException")
|
||||||
|
private suspend fun estimateWithdrawGasLimit(
|
||||||
|
userWallet: UserWallet?,
|
||||||
|
walletManager: EthereumWalletManager,
|
||||||
|
token: CryptoCurrency.Token,
|
||||||
|
): BigInteger {
|
||||||
|
if (userWallet == null) return WITHDRAW_GAS_LIMIT
|
||||||
|
|
||||||
|
val moduleAddress = gaslessYieldRepository.getYieldContractAddress(userWallet.walletId, token)
|
||||||
|
?: return WITHDRAW_GAS_LIMIT
|
||||||
|
|
||||||
|
// The withdraw amount is encoded into the call data: a small fixed probe whose exact value does not
|
||||||
|
// affect the gas cost. It is a token amount because the call data needs the token's contract/decimals.
|
||||||
|
val withdrawAmount = createTokenAmount(
|
||||||
|
token = token,
|
||||||
|
value = BigDecimal(PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS).movePointLeft(token.decimals),
|
||||||
|
)
|
||||||
|
|
||||||
|
val probeCallData = try {
|
||||||
|
gaslessYieldRepository.createPartialWithdrawCallData(
|
||||||
|
userWalletId = userWallet.walletId,
|
||||||
|
cryptoCurrency = token,
|
||||||
|
amount = withdrawAmount,
|
||||||
|
)
|
||||||
|
} catch (e: YieldModuleUpgradeUnavailableException) {
|
||||||
|
return WITHDRAW_GAS_LIMIT
|
||||||
|
} catch (e: YieldModuleVersionIndeterminateException) {
|
||||||
|
return WITHDRAW_GAS_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the real batch sub-call (see CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload):
|
||||||
|
// `to = moduleAddress`, zero native value, withdraw call data. A zero-value Coin amount is required so
|
||||||
|
// that EthereumWalletManager.getGasLimit keeps `to` = moduleAddress — a Token amount would override it
|
||||||
|
// with the token contract address and estimate the wrong call.
|
||||||
|
val estimationAmount = Amount(
|
||||||
|
currencySymbol = token.symbol,
|
||||||
|
value = BigDecimal.ZERO,
|
||||||
|
decimals = token.decimals,
|
||||||
|
type = AmountType.Coin,
|
||||||
|
)
|
||||||
|
|
||||||
|
return when (val result = walletManager.getGasLimit(estimationAmount, moduleAddress, probeCallData)) {
|
||||||
|
is Result.Success -> result.data
|
||||||
|
is Result.Failure -> WITHDRAW_GAS_LIMIT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
|
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
|
||||||
token = Token(
|
token = Token(
|
||||||
symbol = token.symbol,
|
symbol = token.symbol,
|
||||||
|
|
@ -217,6 +306,26 @@ internal class TokenFeeCalculator(
|
||||||
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
|
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
|
||||||
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
|
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback gas for the batch yield-withdraw operation (withdraw + possible module upgrade), used when
|
||||||
|
* the on-chain probe estimation in [estimateWithdrawGasLimit] is unavailable or reverts. Overestimate-safe
|
||||||
|
* because it only inflates maxTokenFee (a cap) and the signed per-call gas limit.
|
||||||
|
*/
|
||||||
|
val WITHDRAW_GAS_LIMIT: BigInteger = BigInteger("150000")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe amount (in the fee token's minimal units) for the `withdraw` gas estimation. Per spec it is a
|
||||||
|
* small fixed value: large enough to simulate a real withdraw, small enough not to exceed the yield
|
||||||
|
* balance. The withdraw gas cost is effectively independent of the amount.
|
||||||
|
*/
|
||||||
|
const val PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS = 10_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback fee-transfer gas limit used when on-chain estimation reverts due to a zero plain balance on the
|
||||||
|
* yield path. TODO: tune against testnet if needs.
|
||||||
|
*/
|
||||||
|
val FALLBACK_FEE_TRANSFER_GAS_LIMIT: BigInteger = BigInteger("100000")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Increases BigDecimal value by specified percentage.
|
* Increases BigDecimal value by specified percentage.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
|
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||||
|
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import com.tangem.domain.transaction.error.GetFeeError
|
import com.tangem.domain.transaction.error.GetFeeError
|
||||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
|
@ -36,6 +39,7 @@ class TokenFeeCalculatorTest {
|
||||||
|
|
||||||
private lateinit var walletManagersFacade: WalletManagersFacade
|
private lateinit var walletManagersFacade: WalletManagersFacade
|
||||||
private lateinit var gaslessTransactionRepository: GaslessTransactionRepository
|
private lateinit var gaslessTransactionRepository: GaslessTransactionRepository
|
||||||
|
private lateinit var gaslessYieldRepository: GaslessYieldRepository
|
||||||
private lateinit var demoConfig: DemoConfig
|
private lateinit var demoConfig: DemoConfig
|
||||||
private lateinit var tokenFeeCalculator: TokenFeeCalculator
|
private lateinit var tokenFeeCalculator: TokenFeeCalculator
|
||||||
|
|
||||||
|
|
@ -49,12 +53,14 @@ class TokenFeeCalculatorTest {
|
||||||
fun setup() {
|
fun setup() {
|
||||||
walletManagersFacade = mockk()
|
walletManagersFacade = mockk()
|
||||||
gaslessTransactionRepository = mockk()
|
gaslessTransactionRepository = mockk()
|
||||||
|
gaslessYieldRepository = mockk()
|
||||||
demoConfig = mockk()
|
demoConfig = mockk()
|
||||||
|
|
||||||
tokenFeeCalculator = TokenFeeCalculator(
|
tokenFeeCalculator = TokenFeeCalculator(
|
||||||
walletManagersFacade = walletManagersFacade,
|
walletManagersFacade = walletManagersFacade,
|
||||||
gaslessTransactionRepository = gaslessTransactionRepository,
|
gaslessTransactionRepository = gaslessTransactionRepository,
|
||||||
demoConfig = demoConfig,
|
demoConfig = demoConfig,
|
||||||
|
gaslessYieldRepository = gaslessYieldRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
mockWalletManager = mockk()
|
mockWalletManager = mockk()
|
||||||
|
|
@ -215,6 +221,9 @@ class TokenFeeCalculatorTest {
|
||||||
assertNotNull(feeExtended)
|
assertNotNull(feeExtended)
|
||||||
assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId)
|
assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId)
|
||||||
assertTrue(feeExtended.transactionFee is TransactionFee.Single)
|
assertTrue(feeExtended.transactionFee is TransactionFee.Single)
|
||||||
|
// main-tx per-call gas = initialFee.gasLimit; no withdraw on the non-yield path
|
||||||
|
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
|
||||||
|
assertNull(feeExtended.withdrawGasLimit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,6 +422,283 @@ class TokenFeeCalculatorTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Yield-path Tests =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With active yield, a token whose plain balance is small (not enough to pay the fee on its own) must NOT
|
||||||
|
* raise NotEnoughFunds — the resolver decides coverage. The gas limit must include the extra withdraw gas.
|
||||||
|
*
|
||||||
|
* Here `userWallet` is not passed (null), so the withdraw gas estimation is skipped and the
|
||||||
|
* deterministic fallback [WITHDRAW_GAS_LIMIT] is used.
|
||||||
|
*
|
||||||
|
* Expected gasLimit breakdown (matching companion constants):
|
||||||
|
* initialFee.gasLimit = 100_000
|
||||||
|
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
|
||||||
|
* baseGas = 21_000
|
||||||
|
* WITHDRAW_GAS_LIMIT = 150_000
|
||||||
|
* total = 337_000
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `calculateTokenFee with active yield but no wallet falls back to WITHDRAW_GAS_LIMIT`() = runTest {
|
||||||
|
// Given
|
||||||
|
val activeYieldStatus = YieldSupplyStatus(
|
||||||
|
isActive = true,
|
||||||
|
isInitialized = true,
|
||||||
|
isAllowedToSpend = true,
|
||||||
|
effectiveProtocolBalance = BigDecimal("100"), // yield covers the rest
|
||||||
|
)
|
||||||
|
val tokenStatus = createMockTokenStatus(
|
||||||
|
balance = BigDecimal("0.001"), // tiny plain balance — insufficient on its own
|
||||||
|
fiatRate = BigDecimal("1"),
|
||||||
|
).withYieldSupplyStatus(activeYieldStatus)
|
||||||
|
|
||||||
|
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
|
||||||
|
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
|
||||||
|
|
||||||
|
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||||
|
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||||
|
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = tokenFeeCalculator.calculateTokenFee(
|
||||||
|
walletManager = mockWalletManager,
|
||||||
|
tokenForPayFeeStatus = tokenStatus,
|
||||||
|
nativeCurrencyStatus = nativeStatus,
|
||||||
|
initialFee = initialFee,
|
||||||
|
isYieldActive = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(result.isRight(), "Expected success on yield path with small plain balance")
|
||||||
|
result.onRight { feeExtended ->
|
||||||
|
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
|
||||||
|
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 = 337_000
|
||||||
|
assertEquals(BigInteger("337000"), fee.gasLimit, "gasLimit must include WITHDRAW_GAS_LIMIT (150000)")
|
||||||
|
// feeTransferGasLimit stored in the fee object = 66_000
|
||||||
|
assertEquals(BigInteger("66000"), fee.feeTransferGasLimit, "feeTransferGasLimit = 60000 * 1.10")
|
||||||
|
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = WITHDRAW_GAS_LIMIT
|
||||||
|
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
|
||||||
|
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With active yield, when getGasLimit reverts due to zero plain balance
|
||||||
|
* (BlockchainSdkError.Ethereum.InsufficientFundsForOperation wrapped in WrappedThrowable),
|
||||||
|
* calculateTokenFee must use the deterministic FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) instead of raising.
|
||||||
|
*
|
||||||
|
* Expected breakdown:
|
||||||
|
* initialFee.gasLimit = 100_000
|
||||||
|
* feeTransferGasLimit = 100_000 * 1.10 = 110_000 (FALLBACK_FEE_TRANSFER_GAS_LIMIT * 1.10)
|
||||||
|
* baseGas = 21_000
|
||||||
|
* WITHDRAW_GAS_LIMIT = 150_000
|
||||||
|
* total gasLimit = 381_000
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `calculateTokenFee with active yield uses fallback gas when transfer estimation reverts with insufficient funds`() =
|
||||||
|
runTest {
|
||||||
|
// Given
|
||||||
|
val activeYieldStatus = YieldSupplyStatus(
|
||||||
|
isActive = true,
|
||||||
|
isInitialized = true,
|
||||||
|
isAllowedToSpend = true,
|
||||||
|
effectiveProtocolBalance = BigDecimal("100"),
|
||||||
|
)
|
||||||
|
// Zero plain balance — exactly the condition that causes estimation revert
|
||||||
|
val tokenStatus = createMockTokenStatus(
|
||||||
|
balance = BigDecimal("0"),
|
||||||
|
fiatRate = BigDecimal("1"),
|
||||||
|
).withYieldSupplyStatus(activeYieldStatus)
|
||||||
|
|
||||||
|
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
|
||||||
|
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
|
||||||
|
|
||||||
|
// Simulate on-chain estimation reverting with InsufficientFundsForOperation
|
||||||
|
val insufficientFundsException =
|
||||||
|
BlockchainSdkError.Ethereum.InsufficientFundsForOperation("insufficient funds for gas")
|
||||||
|
val wrappedError = BlockchainSdkError.WrappedThrowable(insufficientFundsException)
|
||||||
|
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Failure(wrappedError)
|
||||||
|
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||||
|
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = tokenFeeCalculator.calculateTokenFee(
|
||||||
|
walletManager = mockWalletManager,
|
||||||
|
tokenForPayFeeStatus = tokenStatus,
|
||||||
|
nativeCurrencyStatus = nativeStatus,
|
||||||
|
initialFee = initialFee,
|
||||||
|
isYieldActive = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(result.isRight(), "Expected success with fallback gas on yield path")
|
||||||
|
result.onRight { feeExtended ->
|
||||||
|
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
|
||||||
|
// feeTransferGasLimit = FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) * 1.10 = 110_000
|
||||||
|
assertEquals(
|
||||||
|
BigInteger("110000"),
|
||||||
|
fee.feeTransferGasLimit,
|
||||||
|
"feeTransferGasLimit must use fallback (100000 * 1.10 = 110000)",
|
||||||
|
)
|
||||||
|
// gasLimit = 100_000 + 110_000 + 21_000 + 150_000 = 381_000
|
||||||
|
assertEquals(
|
||||||
|
BigInteger("381000"),
|
||||||
|
fee.gasLimit,
|
||||||
|
"gasLimit must include WITHDRAW_GAS_LIMIT (150000)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms that the non-yield path (isYieldActive = false, default) is unchanged:
|
||||||
|
* a token with insufficient plain balance still raises NotEnoughFunds.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `calculateTokenFee without yield still raises NotEnoughFunds on insufficient balance`() = runTest {
|
||||||
|
// Given
|
||||||
|
val tokenStatus = createMockTokenStatus(
|
||||||
|
balance = BigDecimal("0.001"), // very small — insufficient
|
||||||
|
fiatRate = BigDecimal("1"),
|
||||||
|
)
|
||||||
|
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
|
||||||
|
val initialFee = createMockEIP1559Fee()
|
||||||
|
|
||||||
|
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||||
|
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||||
|
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||||
|
|
||||||
|
// When — default isYieldActive = false
|
||||||
|
val result = tokenFeeCalculator.calculateTokenFee(
|
||||||
|
walletManager = mockWalletManager,
|
||||||
|
tokenForPayFeeStatus = tokenStatus,
|
||||||
|
nativeCurrencyStatus = nativeStatus,
|
||||||
|
initialFee = initialFee,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(result.isLeft(), "Non-yield path must still raise NotEnoughFunds for insufficient balance")
|
||||||
|
result.onLeft { error ->
|
||||||
|
assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With active yield AND a wallet, the withdraw gas limit is estimated on-chain via a probe
|
||||||
|
* `withdraw(yieldToken, 10000)` against the yield module. The estimated value (here 200_000) flows into
|
||||||
|
* BOTH the maxTokenFee cap and the signed per-call withdraw gas limit — not the hardcoded fallback.
|
||||||
|
*
|
||||||
|
* Expected gasLimit breakdown:
|
||||||
|
* initialFee.gasLimit = 100_000
|
||||||
|
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
|
||||||
|
* baseGas = 21_000
|
||||||
|
* estimated withdraw = 200_000
|
||||||
|
* total = 387_000
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `calculateTokenFee with active yield and wallet estimates withdraw gas on-chain`() = runTest {
|
||||||
|
// Given
|
||||||
|
val activeYieldStatus = YieldSupplyStatus(
|
||||||
|
isActive = true,
|
||||||
|
isInitialized = true,
|
||||||
|
isAllowedToSpend = true,
|
||||||
|
effectiveProtocolBalance = BigDecimal("100"),
|
||||||
|
)
|
||||||
|
val tokenStatus = createMockTokenStatus(
|
||||||
|
balance = BigDecimal("0.001"),
|
||||||
|
fiatRate = BigDecimal("1"),
|
||||||
|
).withYieldSupplyStatus(activeYieldStatus)
|
||||||
|
|
||||||
|
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
|
||||||
|
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
|
||||||
|
|
||||||
|
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||||
|
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||||
|
// fee-transfer estimation (to the fee receiver) vs. withdraw estimation (to the yield module)
|
||||||
|
coEvery {
|
||||||
|
mockWalletManager.getGasLimit(any(), "0xFeeReceiver", any())
|
||||||
|
} returns Result.Success(BigInteger("60000"))
|
||||||
|
coEvery {
|
||||||
|
mockWalletManager.getGasLimit(any(), "0xModule", any())
|
||||||
|
} returns Result.Success(BigInteger("200000"))
|
||||||
|
coEvery {
|
||||||
|
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
|
||||||
|
} returns "0xModule"
|
||||||
|
coEvery {
|
||||||
|
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
|
||||||
|
} returns mockk<SmartContractCallData>(relaxed = true)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = tokenFeeCalculator.calculateTokenFee(
|
||||||
|
walletManager = mockWalletManager,
|
||||||
|
tokenForPayFeeStatus = tokenStatus,
|
||||||
|
nativeCurrencyStatus = nativeStatus,
|
||||||
|
initialFee = initialFee,
|
||||||
|
isYieldActive = true,
|
||||||
|
userWallet = mockUserWallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(result.isRight(), "Expected success on yield path with on-chain withdraw estimation")
|
||||||
|
result.onRight { feeExtended ->
|
||||||
|
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
|
||||||
|
// gasLimit = 100_000 + 66_000 + 21_000 + 200_000 = 387_000
|
||||||
|
assertEquals(BigInteger("387000"), fee.gasLimit, "gasLimit must include the estimated withdraw gas")
|
||||||
|
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = estimated 200_000
|
||||||
|
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
|
||||||
|
assertEquals(BigInteger("200000"), feeExtended.withdrawGasLimit)
|
||||||
|
}
|
||||||
|
coVerify { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) }
|
||||||
|
coVerify { mockWalletManager.getGasLimit(any(), "0xModule", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the yield module address is unavailable (e.g. module not yet deployed), the on-chain estimation
|
||||||
|
* is skipped and the calculator falls back to [WITHDRAW_GAS_LIMIT] — even though a wallet is provided.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `calculateTokenFee with active yield falls back when yield module address is unavailable`() = runTest {
|
||||||
|
// Given
|
||||||
|
val activeYieldStatus = YieldSupplyStatus(
|
||||||
|
isActive = true,
|
||||||
|
isInitialized = true,
|
||||||
|
isAllowedToSpend = true,
|
||||||
|
effectiveProtocolBalance = BigDecimal("100"),
|
||||||
|
)
|
||||||
|
val tokenStatus = createMockTokenStatus(
|
||||||
|
balance = BigDecimal("0.001"),
|
||||||
|
fiatRate = BigDecimal("1"),
|
||||||
|
).withYieldSupplyStatus(activeYieldStatus)
|
||||||
|
|
||||||
|
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
|
||||||
|
val initialFee = createMockEIP1559Fee()
|
||||||
|
|
||||||
|
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
|
||||||
|
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
|
||||||
|
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
|
||||||
|
coEvery { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) } returns null
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = tokenFeeCalculator.calculateTokenFee(
|
||||||
|
walletManager = mockWalletManager,
|
||||||
|
tokenForPayFeeStatus = tokenStatus,
|
||||||
|
nativeCurrencyStatus = nativeStatus,
|
||||||
|
initialFee = initialFee,
|
||||||
|
isYieldActive = true,
|
||||||
|
userWallet = mockUserWallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(result.isRight())
|
||||||
|
result.onRight { feeExtended ->
|
||||||
|
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 (fallback) = 337_000
|
||||||
|
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
|
||||||
|
assertEquals(BigInteger("337000"), fee.gasLimit)
|
||||||
|
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
|
||||||
|
}
|
||||||
|
// withdraw estimation must NOT be attempted without a module address
|
||||||
|
coVerify(exactly = 0) { gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Helper Methods =====
|
// ===== Helper Methods =====
|
||||||
|
|
||||||
private fun createMockTransactionFee(): TransactionFee {
|
private fun createMockTransactionFee(): TransactionFee {
|
||||||
|
|
@ -455,6 +741,21 @@ class TokenFeeCalculatorTest {
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of this [CryptoCurrencyStatus] mock with [yieldSupplyStatus] overridden.
|
||||||
|
* Since [CryptoCurrencyStatus] is a mockk, we create a new mock that delegates everything and
|
||||||
|
* overrides only [yieldSupplyStatus].
|
||||||
|
*/
|
||||||
|
private fun CryptoCurrencyStatus.withYieldSupplyStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
|
||||||
|
val original = this
|
||||||
|
val newStatus = mockk<CryptoCurrencyStatus>()
|
||||||
|
every { newStatus.currency } returns original.currency
|
||||||
|
every { newStatus.value.amount } returns original.value.amount
|
||||||
|
every { newStatus.value.fiatRate } returns original.value.fiatRate
|
||||||
|
every { newStatus.value.yieldSupplyStatus } returns yieldSupplyStatus
|
||||||
|
return newStatus
|
||||||
|
}
|
||||||
|
|
||||||
private fun createMockNativeCurrencyStatus(
|
private fun createMockNativeCurrencyStatus(
|
||||||
fiatRate: BigDecimal? = BigDecimal("2000"),
|
fiatRate: BigDecimal? = BigDecimal("2000"),
|
||||||
decimals: Int = 18,
|
decimals: Int = 18,
|
||||||
|
|
@ -471,4 +772,4 @@ class TokenFeeCalculatorTest {
|
||||||
|
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,13 +3,14 @@ package com.tangem.domain.yield.supply
|
||||||
import com.tangem.blockchain.common.TransactionData
|
import com.tangem.blockchain.common.TransactionData
|
||||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||||
import com.tangem.blockchain.common.transaction.Fee
|
import com.tangem.blockchain.common.transaction.Fee
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
|
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
interface YieldSupplyTransactionRepository {
|
interface YieldSupplyTransactionRepository : GaslessYieldRepository {
|
||||||
|
|
||||||
suspend fun createEnterTransactions(
|
suspend fun createEnterTransactions(
|
||||||
userWalletId: UserWalletId,
|
userWalletId: UserWalletId,
|
||||||
|
|
@ -23,10 +24,6 @@ interface YieldSupplyTransactionRepository {
|
||||||
fee: Fee?,
|
fee: Fee?,
|
||||||
): TransactionData.Uncompiled
|
): TransactionData.Uncompiled
|
||||||
|
|
||||||
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
|
|
||||||
|
|
||||||
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the version status of the user's yield-module contract and wraps [callData] with an
|
* Checks the version status of the user's yield-module contract and wraps [callData] with an
|
||||||
* upgrade transaction if the deployed version is out of date.
|
* upgrade transaction if the deployed version is out of date.
|
||||||
|
|
@ -36,4 +33,7 @@ interface YieldSupplyTransactionRepository {
|
||||||
network: Network,
|
network: Network,
|
||||||
callData: SmartContractCallData,
|
callData: SmartContractCallData,
|
||||||
): SmartContractCallData
|
): SmartContractCallData
|
||||||
|
|
||||||
|
/** Returns the on-chain version status of the user's yield module for [network]. */
|
||||||
|
suspend fun getYieldModuleVersionStatus(userWalletId: UserWalletId, network: Network): YieldModuleVersionStatus
|
||||||
}
|
}
|
||||||
|
|
@ -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.ExpressEstimate
|
||||||
import com.tangem.common.ui.expressStatus.ExpressHideButton
|
import com.tangem.common.ui.expressStatus.ExpressHideButton
|
||||||
import com.tangem.common.ui.expressStatus.ExpressProvider
|
import com.tangem.common.ui.expressStatus.ExpressProvider
|
||||||
|
import com.tangem.common.ui.expressStatus.ExpressStatusBlock
|
||||||
|
import com.tangem.common.ui.expressStatus.state.ExpressLinkUM
|
||||||
|
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemState
|
||||||
|
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemUM
|
||||||
|
import com.tangem.common.ui.expressStatus.state.ExpressStatusUM
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.core.ui.components.SpacerH
|
import com.tangem.core.ui.components.SpacerH
|
||||||
import com.tangem.core.ui.components.SpacerH10
|
import com.tangem.core.ui.components.SpacerH10
|
||||||
import com.tangem.core.ui.components.SpacerH12
|
import com.tangem.core.ui.components.SpacerH12
|
||||||
|
|
@ -27,7 +33,9 @@ import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
|
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
|
||||||
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
|
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
|
||||||
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ExchangeStatusBottomSheetContent(
|
internal fun ExchangeStatusBottomSheetContent(
|
||||||
|
|
@ -80,11 +88,7 @@ internal fun ExchangeStatusBottomSheetContent(
|
||||||
extraContent()
|
extraContent()
|
||||||
SpacerH12()
|
SpacerH12()
|
||||||
}
|
}
|
||||||
ExchangeStatusBlock(
|
ExpressStatusBlock(state = state.toExpressStatusUM())
|
||||||
statuses = state.statuses,
|
|
||||||
showLink = state.showProviderLink,
|
|
||||||
onClick = { state.info.onGoToProviderClick(state.info.txExternalUrl.orEmpty()) },
|
|
||||||
)
|
|
||||||
if (state.notification != null) {
|
if (state.notification != null) {
|
||||||
Notification(state = state.notification, activeStatus = state.activeStatus)
|
Notification(state = state.notification, activeStatus = state.activeStatus)
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +105,34 @@ internal fun ExchangeStatusBottomSheetContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ExchangeUM.toExpressStatusUM(): ExpressStatusUM = ExpressStatusUM(
|
||||||
|
title = resourceReference(R.string.express_exchange_status_title),
|
||||||
|
link = if (showProviderLink) {
|
||||||
|
ExpressLinkUM.Content(
|
||||||
|
icon = R.drawable.ic_arrow_top_right_24,
|
||||||
|
text = resourceReference(R.string.common_go_to_provider),
|
||||||
|
onClick = { info.onGoToProviderClick(info.txExternalUrl.orEmpty()) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ExpressLinkUM.Empty
|
||||||
|
},
|
||||||
|
statuses = statuses.map { it.toExpressStatusItemUM() }.toImmutableList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ExchangeStatusState.toExpressStatusItemUM(): ExpressStatusItemUM = ExpressStatusItemUM(
|
||||||
|
text = text,
|
||||||
|
state = when {
|
||||||
|
status == ExchangeStatus.Cancelled -> ExpressStatusItemState.Error
|
||||||
|
status.isFailed() || status == ExchangeStatus.Refunded || status == ExchangeStatus.Paused -> {
|
||||||
|
if (isDone) ExpressStatusItemState.Done else ExpressStatusItemState.Error
|
||||||
|
}
|
||||||
|
status == ExchangeStatus.Verifying -> ExpressStatusItemState.Warning
|
||||||
|
isDone -> ExpressStatusItemState.Done
|
||||||
|
isActive -> ExpressStatusItemState.Active
|
||||||
|
else -> ExpressStatusItemState.Default
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Notification(state: ExchangeStatusNotification, activeStatus: ExchangeStatus?) {
|
private fun Notification(state: ExchangeStatusNotification, activeStatus: ExchangeStatus?) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue