Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-13 01:04:38 +05:00
parent 02a0dd59f9
commit 49bef29d23
10 changed files with 452 additions and 43 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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())

View file

@ -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]

View file

@ -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.

View file

@ -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

View file

@ -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`).

View file

@ -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,

View file

@ -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