Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-10 19:49:57 +03:00
parent c8b7934d93
commit bdf2433cb7
7 changed files with 317 additions and 20 deletions

View file

@ -363,4 +363,23 @@ internal object TransactionDomainModule {
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
)
}
@Provides
@Singleton
fun provideEstimateFeeForGaslessTxUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
estimateFeeUseCase: EstimateFeeUseCase,
): EstimateFeeForGaslessTxUseCase {
return EstimateFeeForGaslessTxUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
estimateFeeUseCase = estimateFeeUseCase,
)
}
}

View file

@ -0,0 +1,183 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
class EstimateFeeForGaslessTxUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val currenciesRepository: CurrenciesRepository,
private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
private val estimateFeeUseCase: EstimateFeeUseCase,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
)
suspend operator fun invoke(
userWallet: UserWallet,
amount: BigDecimal,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
catch(
block = {
val network = cryptoCurrencyStatus.currency.network
val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = network.id,
derivationPath = network.derivationPath,
)
if (!gaslessTransactionRepository.isNetworkSupported(network)) {
estimateFeeUseCase.invoke(
userWallet = userWallet,
amount = amount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
).fold(
ifLeft = { raise(it) },
ifRight = { fee ->
return@either TransactionFeeExtended(
transactionFee = fee,
feeTokenId = nativeCurrency.id,
)
},
)
}
val walletManager = prepareWalletManager(userWallet, network)
val initialFee = tokenFeeCalculator.estimateInitialFee(
userWallet = userWallet,
amount = amount,
tokenCurrencyStatus = cryptoCurrencyStatus,
).bind()
selectFeePaymentStrategy(
userWallet = userWallet,
walletManager = walletManager,
nativeCurrency = nativeCurrency,
network = network,
initialFee = initialFee,
)
},
catch = {
raise(GetFeeError.DataError(it))
},
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.prepareWalletManager(
userWallet: UserWallet,
network: Network,
): EthereumWalletManager {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWallet.walletId,
network = network,
)
val ethereumWalletManager = walletManager as? EthereumWalletManager
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
return ethereumWalletManager
}
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
userWallet: UserWallet,
walletManager: EthereumWalletManager,
nativeCurrency: CryptoCurrency,
network: Network,
initialFee: TransactionFee,
): TransactionFeeExtended {
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
val userCurrenciesStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull() ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val networkCurrenciesStatuses = userCurrenciesStatuses.filter {
it.currency.network.id == network.id
}
val nativeCurrencyStatus = networkCurrenciesStatuses.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${network.id}")
val nativeBalance = nativeCurrencyStatus.value.amount ?: BigDecimal.ZERO
return if (nativeBalance >= feeValue) {
TransactionFeeExtended(transactionFee = initialFee, feeTokenId = nativeCurrencyStatus.currency.id)
} else {
findTokensToPayFee(
walletManager = walletManager,
initialTxFee = initialFee,
nativeCurrencyStatus = nativeCurrencyStatus,
networkCurrenciesStatuses = networkCurrenciesStatuses,
)
}
}
@Suppress("NullableToStringCall")
private suspend fun Raise<GetFeeError>.findTokensToPayFee(
walletManager: EthereumWalletManager,
initialTxFee: TransactionFee,
nativeCurrencyStatus: CryptoCurrencyStatus,
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
): TransactionFeeExtended {
val initialFee = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
error = "only Fee.Ethereum supported, but was ${initialTxFee.normal::class.qualifiedName}",
)
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress
}.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount }
.filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
token.contractAddress.lowercase() in supportedGaslessTokens
}
/**
* Selects token with highest balance to maximize chances of successful fee payment.
* Returns null if no suitable tokens found.
*/
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
?: raise(GaslessError.NoSupportedTokensFound)
return tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenForPayFeeStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFee,
).bind()
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.blockchain.extensions.Result
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
@ -38,24 +39,15 @@ class EstimateFeeForTokenUseCase(
suspend operator fun invoke(
userWallet: UserWallet,
token: CryptoCurrency,
tokenCurrencyStatus: CryptoCurrencyStatus,
amount: BigDecimal,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
val token = tokenCurrencyStatus.currency
if (!gaslessTransactionRepository.isNetworkSupported(token.network)) {
raise(GetFeeError.GaslessError.NetworkIsNotSupported)
}
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull()?.filter {
it.currency.network.id == token.network.id
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val tokenCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == token.id
} ?: raiseIllegalStateError("token currency not found for network ${token.network.id}")
val amountData = amount.convertToSdkAmount(tokenCurrencyStatus)
val result = if (userWallet is UserWallet.Cold &&
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
@ -89,6 +81,12 @@ class EstimateFeeForTokenUseCase(
derivationPath = token.network.derivationPath,
)
val userCurrenciesStatusesByNetwork = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWallet.walletId,
).getOrNull()?.filter {
it.currency.network.id == token.network.id
} ?: raiseIllegalStateError("currencies list is null for userWalletId=${userWallet.walletId}")
val nativeCurrencyStatus = userCurrenciesStatusesByNetwork.find {
it.currency.id == nativeCurrency.id
} ?: raiseIllegalStateError("native currency not found for network ${token.network.id}")

View file

@ -23,6 +23,7 @@ import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.error.mapToFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
import java.math.RoundingMode
@ -56,6 +57,37 @@ internal class TokenFeeCalculator(
}
}
suspend fun estimateInitialFee(
userWallet: UserWallet,
amount: BigDecimal,
tokenCurrencyStatus: CryptoCurrencyStatus,
): Either<GetFeeError, TransactionFee> {
return either {
val network = tokenCurrencyStatus.currency.network
val amountData = amount.convertToSdkAmount(tokenCurrencyStatus)
val result = if (userWallet is UserWallet.Cold &&
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
) {
demoTransactionSender(userWallet, network).estimateFee(
amount = amountData,
destination = "",
)
} else {
walletManagersFacade.estimateFee(
amount = amountData,
userWalletId = userWallet.walletId,
network = network,
)
}
when (result) {
is Result.Success -> result.data
is Result.Failure -> raise(result.mapToFeeError())
null -> raise(GetFeeError.UnknownError)
}
}
}
suspend fun calculateTokenFee(
walletManager: EthereumWalletManager,
tokenForPayFeeStatus: CryptoCurrencyStatus,

View file

@ -90,6 +90,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
params = FeeSelectorBlockParams(
state = model.uiState.value.feeSelectorUM,
onLoadFee = model::loadFee,
onLoadFeeExtended = model::loadFeeExtended,
feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus,
cryptoCurrencyStatus = model.primaryCurrencyStatus,
feeStateConfiguration = FeeStateConfiguration.ExcludeLow,

View file

@ -3,6 +3,7 @@ package com.tangem.features.swap.v2.impl.sendviaswap.confirm.model
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
@ -26,7 +27,10 @@ import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
@ -64,6 +68,7 @@ import jakarta.inject.Inject
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned
import com.tangem.utils.transformer.update as transformerUpdate
@Suppress("LongParameterList", "LargeClass")
@ -73,6 +78,8 @@ internal class SendWithSwapConfirmModel @Inject constructor(
private val router: Router,
private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase,
private val estimateFeeUseCase: EstimateFeeUseCase,
private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase,
private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val sendNotificationsUpdateTrigger: SendNotificationsUpdateTrigger,
@ -106,6 +113,8 @@ internal class SendWithSwapConfirmModel @Inject constructor(
get() = uiState.value.destinationUM as? DestinationUM.Content
private val feeSelectorUM
get() = uiState.value.feeSelectorUM as? FeeSelectorUM.Content
private val feeUMV2
get() = uiState.value.feeSelectorUM as? FeeSelectorUMRedesigned.Content
val secondaryCurrencyStatus: CryptoCurrencyStatus? = amountUM?.secondaryCryptoCurrencyStatus
val secondaryCurrency: CryptoCurrency = requireNotNull(amountUM?.secondaryCryptoCurrencyStatus?.currency) {
@ -143,7 +152,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
init {
initAmountSubtractAvailability()
updateAmountSubtractAvailability()
configConfirmNavigation()
initialState()
subscribeOnNotificationUpdates()
@ -152,6 +161,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
uiState.update { it.copy(feeSelectorUM = feeSelectorUM) }
updateAmountSubtractAvailability()
updateConfirmNotifications()
}
@ -243,11 +253,43 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
}
suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either<GetFeeError, TransactionFeeExtended> {
val defaultError = GetFeeError.UnknownError.left()
val provider = (confirmData.quote as? SwapQuoteUM.Content)?.provider ?: return defaultError
val amountValue = confirmData.enteredAmount ?: return defaultError
return when (val providerType = provider.type) {
ExpressProviderType.CEX -> {
if (maybeToken != null) {
estimateFeeForTokenUseCase(
amount = amountValue,
userWallet = params.userWallet,
tokenCurrencyStatus = maybeToken,
)
} else {
estimateFeeForGaslessTxUseCase(
amount = amountValue,
userWallet = params.userWallet,
cryptoCurrencyStatus = primaryCurrencyStatus,
)
}
}
ExpressProviderType.DEX,
ExpressProviderType.DEX_BRIDGE,
ExpressProviderType.ONRAMP,
-> GetFeeError.DataError(
cause = IllegalStateException("Provider $providerType is not supported in Send With Swap"),
).left()
}
}
private fun onSendClick() {
val provider = confirmData.quote?.provider ?: return
modelScope.launch {
uiState.transformerUpdate(SendWithSwapConfirmSendingStateTransformer(true))
val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended
swapTransactionSender.sendTransaction(
feeExtended = feeExtended,
confirmData = confirmData,
isAmountSubtractAvailable = isAmountSubtractAvailable,
onExpressError = { expressError ->
@ -305,12 +347,15 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
}
private fun initAmountSubtractAvailability() {
private fun updateAmountSubtractAvailability() {
modelScope.launch {
val isGaslessEthTx =
feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency
isAmountSubtractAvailable =
isAmountSubtractAvailableUseCase(
params.userWallet.walletId,
primaryCurrencyStatus.currency,
userWalletId = params.userWallet.walletId,
currency = primaryCurrencyStatus.currency,
isGaslessEthTx = isGaslessEthTx,
).getOrElse { false }
}
}

View file

@ -17,8 +17,10 @@ import com.tangem.domain.swap.models.SwapTxType
import com.tangem.domain.swap.usecase.GetSwapDataUseCase
import com.tangem.domain.swap.usecase.SwapTransactionSentUseCase
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.swap.v2.impl.common.ConfirmData
@ -35,6 +37,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val swapTransactionSentUseCase: SwapTransactionSentUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
@Assisted private val userWallet: UserWallet,
) {
@ -45,6 +48,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
onExpressError: (ExpressError) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
expressOperationType: ExpressOperationType,
feeExtended: TransactionFeeExtended?,
) {
val provider = confirmData.quote?.provider ?: return
@ -56,6 +60,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
onSendSuccess = onSendSuccess,
onSendError = onSendError,
expressOperationType = expressOperationType,
feeExtended = feeExtended,
)
ExpressProviderType.DEX,
ExpressProviderType.DEX_BRIDGE,
@ -74,6 +79,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
onExpressError: (ExpressError) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
expressOperationType: ExpressOperationType,
feeExtended: TransactionFeeExtended?,
) {
val fromStatus = confirmData.fromCryptoCurrencyStatus ?: return
val toStatus = confirmData.toCryptoCurrencyStatus ?: return
@ -114,6 +120,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
onSendSuccess = onSendSuccess,
onExpressError = onExpressError,
onSendError = onSendError,
feeExtended = feeExtended,
)
}
@ -123,6 +130,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
toStatus: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
fee: Fee,
feeExtended: TransactionFeeExtended?,
provider: ExpressProvider,
swapData: SwapDataModel,
onSendSuccess: (String, Long, SwapDataModel) -> Unit,
@ -161,11 +169,22 @@ internal class SwapTransactionSender @AssistedInject constructor(
txData.destinationAddress
}
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = fromStatus.currency.network,
).fold(
val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency
val result = if (feeExtended != null && isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
userWallet = userWallet,
transactionData = txData,
fee = feeExtended,
)
} else {
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = fromStatus.currency.network,
)
}
result.fold(
ifLeft = { onSendError(it) },
ifRight = { txHash ->
val timestamp = System.currentTimeMillis()