Updated on 2026-08-14
This commit is contained in:
parent
02a0dd59f9
commit
49bef29d23
10 changed files with 452 additions and 43 deletions
|
|
@ -600,6 +600,295 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
override suspend fun onSwapWithUnifiedFee(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
swapProvider: SwapProvider,
|
||||
swapData: SwapDataModel?,
|
||||
amountToSwap: String,
|
||||
includeFeeInAmount: IncludeFeeInAmount,
|
||||
fee: SwapFee?,
|
||||
expressOperationType: ExpressOperationType,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
): SwapTransactionState {
|
||||
TangemLogger.i(
|
||||
"""
|
||||
Swap (unified fee)
|
||||
|- swapProvider: $swapProvider
|
||||
|- swapData: $swapData
|
||||
|- fromSwapCurrencyStatus:
|
||||
|---- walletId: ${fromSwapCurrencyStatus.userWalletId}
|
||||
|---- accountId: ${fromSwapCurrencyStatus.account.accountId}
|
||||
|---- currencyId: ${fromSwapCurrencyStatus.currency.id}
|
||||
|- toSwapCurrencyStatus: $toSwapCurrencyStatus
|
||||
|---- walletId: ${toSwapCurrencyStatus.userWalletId}
|
||||
|---- accountId: ${toSwapCurrencyStatus.account.accountId}
|
||||
|---- currencyId: ${toSwapCurrencyStatus.currency.id}
|
||||
|- amountToSwap: $amountToSwap
|
||||
|- includeFeeInAmount: $includeFeeInAmount
|
||||
|- fee: $fee
|
||||
""".trimIndent(),
|
||||
shouldSanitize = false,
|
||||
)
|
||||
|
||||
val userWallet = fromSwapCurrencyStatus.userWallet
|
||||
if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) {
|
||||
return SwapTransactionState.DemoMode
|
||||
}
|
||||
|
||||
return when (swapProvider.type) {
|
||||
ExchangeProviderType.CEX -> {
|
||||
val amountDecimal = toBigDecimalOrNull(amountToSwap)
|
||||
val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals)
|
||||
val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
|
||||
includeFeeInAmount.amountSubtractFee
|
||||
} else {
|
||||
amount
|
||||
}
|
||||
onSwapCexUnified(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amount = amountToSwapWithFee,
|
||||
swapFee = fee,
|
||||
swapProvider = swapProvider,
|
||||
expressOperationType = expressOperationType,
|
||||
isTangemPayWithdrawal = isTangemPayWithdrawal,
|
||||
)
|
||||
}
|
||||
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
|
||||
val networkId = fromSwapCurrencyStatus.currency.network.rawId
|
||||
if (isSolana(networkId)) {
|
||||
onSwapSolanaDex(
|
||||
provider = swapProvider,
|
||||
swapData = requireNotNull(swapData),
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amountToSwap = amountToSwap,
|
||||
)
|
||||
} else {
|
||||
if (fee == null) return SwapTransactionState.Error.UnknownError
|
||||
onSwapDexUnified(
|
||||
provider = swapProvider,
|
||||
swapData = requireNotNull(swapData),
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
swapFee = fee,
|
||||
amountToSwap = amountToSwap,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — Phase 4. DEX swap dispatch using [SwapFee]. Mirrors [onSwapDex] exactly,
|
||||
* substituting `SwapFee.fee` where the legacy code used `TxFee.fee`. Solana DEX continues
|
||||
* to use [onSwapSolanaDex] which doesn't consume a fee.
|
||||
*/
|
||||
private suspend fun onSwapDexUnified(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
provider: SwapProvider,
|
||||
swapData: SwapDataModel,
|
||||
amountToSwap: String,
|
||||
swapFee: SwapFee,
|
||||
): SwapTransactionState {
|
||||
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
|
||||
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
|
||||
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
|
||||
val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
|
||||
val dataToSign = dexTransaction.txData
|
||||
val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network)
|
||||
val txData = createTransactionUseCase(
|
||||
amount = amountToSend,
|
||||
fee = swapFee.fee,
|
||||
memo = null,
|
||||
destination = swapData.transaction.txTo,
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = toSwapCurrencyStatus.currency.network,
|
||||
txExtras = createDexTxExtras(
|
||||
dataToSign,
|
||||
fromSwapCurrencyStatus.currency.network,
|
||||
swapFee.fee.getGasLimit(),
|
||||
),
|
||||
).getOrElse { error ->
|
||||
TangemLogger.e("Failed to create swap dex tx data", error)
|
||||
return SwapTransactionState.Error.UnknownError
|
||||
}
|
||||
|
||||
return handleSwapResult(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
swapData = swapData,
|
||||
amount = amount,
|
||||
txData = txData,
|
||||
payInAddress = getPayoutAddress(txData),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — Phase 4. CEX swap dispatch using [SwapFee]. Mirrors [onSwapCex] exactly.
|
||||
*
|
||||
* Branch selection (matches legacy):
|
||||
* - Gasless token path: `swapFee.transactionFeeResult is LoadedExtended && selectedFeeToken.currency is Token`
|
||||
* → `createAndSendGaslessTransactionUseCase`.
|
||||
* - Otherwise → `sendTransactionUseCase` with `swapFee.fee`.
|
||||
*/
|
||||
@Suppress("LongMethod", "CanBeNonNullable")
|
||||
private suspend fun onSwapCexUnified(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
swapFee: SwapFee?,
|
||||
swapProvider: SwapProvider,
|
||||
expressOperationType: ExpressOperationType,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
): SwapTransactionState {
|
||||
val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress
|
||||
val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty()
|
||||
val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress
|
||||
val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty()
|
||||
val exchangeData = repository.getExchangeData(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(),
|
||||
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(),
|
||||
fromAddress = fromAddress,
|
||||
toNetwork = toSwapCurrencyStatus.currency.network.rawId,
|
||||
fromAmount = amount.toStringWithRightOffset(),
|
||||
fromDecimals = amount.decimals,
|
||||
toDecimals = toSwapCurrencyStatus.currency.decimals,
|
||||
providerId = swapProvider.providerId,
|
||||
rateType = RateType.FLOAT,
|
||||
expressOperationType = expressOperationType,
|
||||
toAddress = toAddress,
|
||||
refundAddress = fromNetworkAddress?.defaultAddress?.value,
|
||||
refundExtraId = null, // currently always null,
|
||||
).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) }
|
||||
|
||||
val exchangeDataCex =
|
||||
exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError
|
||||
|
||||
if (isTangemPayWithdrawal) {
|
||||
return SwapTransactionState.TangemPayWithdrawalData(
|
||||
cryptoAmount = amount.value,
|
||||
cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId),
|
||||
cexAddress = exchangeDataCex.txTo,
|
||||
fromAmount = amountFormatter.formatSwapAmountToUI(
|
||||
amount,
|
||||
fromSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
fromAmountValue = amount.value,
|
||||
toAmount = amountFormatter.formatSwapAmountToUI(
|
||||
exchangeData.toTokenAmount,
|
||||
toSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
toAmountValue = exchangeData.toTokenAmount.value,
|
||||
storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
swapProvider = swapProvider,
|
||||
swapDataModel = exchangeData,
|
||||
txExternalUrl = exchangeDataCex.externalTxUrl,
|
||||
txExternalId = exchangeDataCex.externalTxId,
|
||||
averageDuration = null,
|
||||
),
|
||||
exchangeData = TangemPayWithdrawExchangeState(
|
||||
txId = exchangeDataCex.txId,
|
||||
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(),
|
||||
payInAddress = exchangeData.transaction.txTo,
|
||||
payInExtraId = exchangeDataCex.txExtraId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val userWallet = fromSwapCurrencyStatus.userWallet
|
||||
if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) {
|
||||
return SwapTransactionState.Error.UnknownError
|
||||
}
|
||||
val fee = requireNotNull(swapFee)
|
||||
val txData = createTransferTransactionUseCase(
|
||||
amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status),
|
||||
fee = fee.fee,
|
||||
memo = exchangeDataCex.txExtraId,
|
||||
destination = exchangeDataCex.txTo,
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
).getOrElse { error ->
|
||||
TangemLogger.e("Failed to create swap CEX tx data", error)
|
||||
return SwapTransactionState.Error.UnknownError
|
||||
}
|
||||
|
||||
if (txData.extras == null && exchangeDataCex.txExtraId != null) {
|
||||
return SwapTransactionState.Error.UnknownError
|
||||
}
|
||||
|
||||
val isGaslessToken = fee.selectedFeeToken.currency is CryptoCurrency.Token &&
|
||||
fee.transactionFeeResult is TransactionFeeResult.LoadedExtended
|
||||
val result = if (isGaslessToken) {
|
||||
createAndSendGaslessTransactionUseCase.invoke(
|
||||
transactionData = txData,
|
||||
userWallet = userWallet,
|
||||
fee = (fee.transactionFeeResult as TransactionFeeResult.LoadedExtended).fee,
|
||||
)
|
||||
} else {
|
||||
sendTransactionUseCase(
|
||||
txData = txData,
|
||||
userWallet = userWallet,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
)
|
||||
}
|
||||
|
||||
val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress
|
||||
val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty()
|
||||
return result.fold(
|
||||
ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) },
|
||||
ifRight = { txHash ->
|
||||
repository.exchangeSent(
|
||||
userWallet = userWallet,
|
||||
txId = exchangeDataCex.txId,
|
||||
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
fromAddress = cexFromAddress,
|
||||
payInAddress = getPayoutAddress(txData),
|
||||
txHash = txHash,
|
||||
payInExtraId = exchangeDataCex.txExtraId,
|
||||
)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val txExternalUrl = exchangeDataCex.externalTxUrl
|
||||
storeSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
swapProvider = swapProvider,
|
||||
swapDataModel = exchangeData,
|
||||
timestamp = timestamp,
|
||||
txExternalUrl = txExternalUrl,
|
||||
txExternalId = exchangeDataCex.externalTxId,
|
||||
)
|
||||
storeLastCryptoCurrencyId(toSwapCurrencyStatus)
|
||||
SwapTransactionState.TxSent(
|
||||
fromAmount = amountFormatter.formatSwapAmountToUI(
|
||||
amount,
|
||||
fromSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
fromAmountValue = amount.value,
|
||||
toAmount = amountFormatter.formatSwapAmountToUI(
|
||||
exchangeData.toTokenAmount,
|
||||
toSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
toAmountValue = exchangeData.toTokenAmount.value,
|
||||
txHash = txHash,
|
||||
txExternalUrl = txExternalUrl,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onSwapDex(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -1291,7 +1580,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behaviour is preserved: when
|
||||
* [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
|
||||
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
|
||||
* decides native vs token. The resulting `SwapFee.selectedFeeToken` is the explicit choice
|
||||
* if provided, otherwise the native coin status of the from-token's network.
|
||||
|
|
@ -1335,6 +1624,79 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
).getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — Phase 4. Patches an existing [SwapState.QuotesLoadedState] with a freshly
|
||||
* resolved [SwapFee]. See [SwapInteractor.applySwapFee] for the full contract.
|
||||
*
|
||||
* Numeric fee used for downstream computation:
|
||||
* - If `fee.selectedFeeToken.currency` is a token → `0` for the balance/include-fee math when
|
||||
* the fee currency differs from the from-token (matches legacy `manageWarnings` semantics
|
||||
* at line 422 of the pre-Phase-4 code).
|
||||
* - Otherwise → `fee.fee.amount.value + fee.otherNativeFee` (the bridge-aware native fee).
|
||||
*
|
||||
* The same `feeToCheck` is fed into `getFeeState`, `isBalanceEnough` and `getIncludeFeeInAmount`
|
||||
* for consistency with the legacy `loadDexSwapData` path.
|
||||
*/
|
||||
override suspend fun applySwapFee(
|
||||
state: SwapState.QuotesLoadedState,
|
||||
fee: SwapFee,
|
||||
lastReducedBalanceBy: BigDecimal,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val fromSwapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus
|
||||
val amount = state.fromTokenInfo.tokenAmount
|
||||
val isFeeInToken = fee.selectedFeeToken.currency is CryptoCurrency.Token
|
||||
val nativeFee = (fee.fee.amount.value ?: BigDecimal.ZERO) + fee.otherNativeFee
|
||||
|
||||
// Mirrors legacy manageWarnings: token-fee paths skip the native deduction.
|
||||
val warningsFee = if (isFeeInToken && fromSwapCurrencyStatus.currency.id != fee.selectedFeeToken.currency.id) {
|
||||
BigDecimal.ZERO
|
||||
} else {
|
||||
nativeFee
|
||||
}
|
||||
|
||||
val feeState = getFeeState(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
fee = nativeFee,
|
||||
spendAmount = amount,
|
||||
selectedFeeToken = fee.selectedFeeToken,
|
||||
)
|
||||
val isBalanceIncludeFeeEnough = isBalanceEnough(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
fee = nativeFee,
|
||||
)
|
||||
val includeFeeInAmount = getIncludeFeeInAmount(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
reduceBalanceBy = lastReducedBalanceBy,
|
||||
feeValue = nativeFee,
|
||||
selectedFeeToken = fee.selectedFeeToken,
|
||||
)
|
||||
val currencyCheck = manageWarnings(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
fee = warningsFee,
|
||||
includeFeeInAmount = includeFeeInAmount,
|
||||
)
|
||||
val validationResult = manageTransactionValidationWarnings(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
feeValue = nativeFee,
|
||||
)
|
||||
val minAdaValue = (fee.fee as? Fee.CardanoToken)?.minAdaValue
|
||||
|
||||
return state.copy(
|
||||
preparedSwapConfigState = state.preparedSwapConfigState.copy(
|
||||
isBalanceEnough = isBalanceIncludeFeeEnough,
|
||||
feeState = feeState,
|
||||
includeFeeInAmount = includeFeeInAmount,
|
||||
),
|
||||
currencyCheck = currencyCheck,
|
||||
validationResult = validationResult,
|
||||
minAdaValue = minAdaValue,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) {
|
||||
swapTransactionRepository.storeLastSwappedCryptoCurrencyId(
|
||||
userWalletId = swapCurrencyStatus.userWalletId,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
|
|||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
|
|
@ -67,6 +68,7 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase()
|
|||
} returns Unit.right()
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
|
||||
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
|
||||
}
|
||||
|
||||
|
|
@ -224,9 +226,11 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase()
|
|||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
hasOutgoingTransaction = false,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
),
|
||||
permissionState = PermissionDataState.Empty,
|
||||
swapDataModel = null,
|
||||
txFee = TxFeeState.Empty,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
|
||||
|
||||
)
|
||||
|
||||
// Then — has a result entry for the DEX provider; type of state is decided by internal logic
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
|||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFeeState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -74,6 +76,7 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
|
|||
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns emptySet()
|
||||
coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right()
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
|
||||
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
|
||||
coEvery {
|
||||
getAllowanceInfoUseCase.invoke(any(), any(), any(), any())
|
||||
|
|
|
|||
|
|
@ -27,15 +27,10 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
|||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -56,6 +51,20 @@ import java.math.BigInteger
|
|||
* [REDACTED_TASK_KEY] — these tests are intentionally pinned to the **current** behavior so that the
|
||||
* upcoming refactor (extraction into `DexSwapFeeCalculator`) is provably equivalent.
|
||||
*/
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] Phase 4 — `findBestQuote` no longer loads fees. The legacy `loadDexSwapData` is gone,
|
||||
* replaced by `loadDexSwapDataNoFee` (no fee calls inside). Fee-loading characterization that was
|
||||
* previously exercised via `findBestQuote` is now covered by:
|
||||
* - `DexSwapFeeCalculatorTest` — for the raw fee strategy (EVM, Solana, fallback, size guard)
|
||||
* - `SwapInteractorImplLoadSwapFeeTest` — for the unified entry point through `loadSwapFee`
|
||||
* - `SwapInteractorImplApplySwapFeeTest` — for fee → state patching semantics
|
||||
*
|
||||
* This class is kept in source for reference and disabled. Phase 5 removes it.
|
||||
*/
|
||||
@Disabled(
|
||||
"[REDACTED_TASK_KEY] Phase 4: findBestQuote no longer loads fees. " +
|
||||
"See DexSwapFeeCalculatorTest, SwapInteractorImplLoadSwapFeeTest, SwapInteractorImplApplySwapFeeTest.",
|
||||
)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase() {
|
||||
|
||||
|
|
@ -195,8 +204,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
// Then — captured TransactionData carries the values from ExpressTransactionModel.DEX
|
||||
assertThat(capturedTxData.isCaptured).isTrue()
|
||||
|
|
@ -265,8 +274,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
// Then — native-balance == 0 raises ExpressDataError.UnknownError up to SwapError
|
||||
val state = result[dexProvider]
|
||||
|
|
@ -342,8 +351,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
// Then — fallback path is invoked with the gas from the express transaction model
|
||||
coVerify(exactly = 1) {
|
||||
|
|
@ -426,8 +435,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
coVerify(exactly = 1) {
|
||||
|
|
@ -507,8 +516,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
coVerify(exactly = 1) {
|
||||
|
|
@ -599,8 +608,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
// Then — TransactionData passed to getFeeUseCase is Compiled (not Uncompiled)
|
||||
assertThat(capturedTxData.isCaptured).isTrue()
|
||||
|
|
@ -678,8 +687,8 @@ internal class SwapInteractorImplLoadFeeForDexTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
val state = result[dexProvider]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import io.mockk.every
|
|||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -48,6 +49,19 @@ import java.math.BigInteger
|
|||
* [REDACTED_TASK_KEY] — these tests exist to guarantee that the upcoming refactor does not silently
|
||||
* drop the bridge protocol fee for DEX_BRIDGE providers.
|
||||
*/
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] Phase 4 — bridge `otherNativeFee` no longer flows through `findBestQuote` (fees aren't
|
||||
* computed during quotes). The bridge-fee balance check is now exercised by
|
||||
* `SwapInteractorImplApplySwapFeeTest` where `SwapFee.otherNativeFee` feeds the recomputed
|
||||
* `feeToCheck = swapFee.fee + otherNativeFee`. The raw propagation from
|
||||
* `ExpressTransactionModel.DEX.otherNativeFeeWei` is covered by `DexSwapFeeCalculatorTest`.
|
||||
*
|
||||
* This class is kept in source for reference and disabled. Phase 5 removes it.
|
||||
*/
|
||||
@Disabled(
|
||||
"[REDACTED_TASK_KEY] Phase 4: otherNativeFee no longer flows through findBestQuote. " +
|
||||
"See SwapInteractorImplApplySwapFeeTest and DexSwapFeeCalculatorTest.",
|
||||
)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase() {
|
||||
|
||||
|
|
@ -170,7 +184,7 @@ internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase
|
|||
providers = listOf(dexBridgeProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then — the bridge provider produces a QuotesLoadedState (no SwapError)
|
||||
|
|
@ -222,7 +236,7 @@ internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase
|
|||
providers = listOf(dexBridgeProvider),
|
||||
amountToSwap = "10",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then — feeToCheckFunds (0.006) > nativeBalance (0.002) → NotEnough
|
||||
|
|
@ -268,7 +282,7 @@ internal class SwapInteractorImplOtherNativeFeeTest : SwapInteractorImplTestBase
|
|||
providers = listOf(dexBridgeProvider),
|
||||
amountToSwap = "10",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then — without otherNativeFee, the same balance is now sufficient.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import com.tangem.domain.transaction.error.GetFeeError
|
|||
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
|
||||
|
|
|
|||
|
|
@ -1146,7 +1146,7 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
modelScope.launch(dispatchers.main) {
|
||||
runCatching(dispatchers.io) {
|
||||
swapInteractor.onSwap(
|
||||
swapInteractor.onSwapWithUnifiedFee(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
swapProvider = provider,
|
||||
|
|
@ -1989,6 +1989,34 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — Phase 4. Extracts the bridge protocol fee from the cached
|
||||
* [SwapDataModel.transaction] payload (DEX bridge providers carry `otherNativeFeeWei`).
|
||||
*
|
||||
* The UI's `FeeSelectorUM` doesn't carry this value, so we read it from the most-recent
|
||||
* swap data. Returns [BigDecimal.ZERO] when no swap data is cached, the transaction is not
|
||||
* a DEX payload, or `otherNativeFeeWei` is null (non-bridge providers).
|
||||
*/
|
||||
private fun resolveOtherNativeFee(): BigDecimal {
|
||||
val transaction =
|
||||
dataState.swapDataModel?.transaction as? ExpressTransactionModel.DEX
|
||||
?: return BigDecimal.ZERO
|
||||
val otherNativeFeeWei = transaction.otherNativeFeeWei ?: return BigDecimal.ZERO
|
||||
val nativeDecimals = dataState.fromSwapCurrencyStatus?.currency?.network?.let { network ->
|
||||
Blockchain.fromNetworkId(network.rawId)?.decimals()
|
||||
} ?: return BigDecimal.ZERO
|
||||
return otherNativeFeeWei.movePointLeft(nativeDecimals)
|
||||
}
|
||||
|
||||
private fun com.tangem.features.send.v2.api.entity.FeeItem.toFeeBucket(): FeeBucket = when (this) {
|
||||
is com.tangem.features.send.v2.api.entity.FeeItem.Slow -> FeeBucket.SLOW
|
||||
is com.tangem.features.send.v2.api.entity.FeeItem.Market -> FeeBucket.MARKET
|
||||
is com.tangem.features.send.v2.api.entity.FeeItem.Fast -> FeeBucket.FAST
|
||||
is com.tangem.features.send.v2.api.entity.FeeItem.Suggested -> FeeBucket.SUGGESTED
|
||||
is com.tangem.features.send.v2.api.entity.FeeItem.Custom -> FeeBucket.CUSTOM
|
||||
is com.tangem.features.send.v2.api.entity.FeeItem.Loading -> FeeBucket.MARKET
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — Phase 4. Extracts the bridge protocol fee from the cached
|
||||
* [SwapDataModel.transaction] payload (DEX bridge providers carry `otherNativeFeeWei`).
|
||||
|
|
|
|||
|
|
@ -2,24 +2,14 @@ package com.tangem.feature.swap
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.RateType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFeeState
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.SwapStateHolder
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
|
|
@ -76,6 +66,7 @@ internal class StateBuilderFeeStateTest {
|
|||
appCurrencyProvider = appCurrencyProvider,
|
||||
isAccountsModeProvider = isAccountsModeProvider,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
shouldShowAbMenu = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +297,7 @@ internal class StateBuilderFeeStateTest {
|
|||
return sut.createInitialReadyState(
|
||||
uiStateHolder = sut.createInitialLoadingState(),
|
||||
emptyAmountState = SwapState.EmptyAmountState(
|
||||
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
|
||||
zeroAmountEquivalent = stringReference("$0.00"),
|
||||
),
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import com.tangem.common.ui.notifications.NotificationId
|
|||
import com.tangem.common.ui.userwallet.handle
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.core.analytics.models.Basic.ButtonSupport
|
||||
import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue