21 KiB
Swap Feature
Token-to-token exchange feature. Users select FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute swaps.
Module Structure
features/swap/
api/ — Public contracts (SwapComponent, SwapFeatureToggles)
impl/ — UI, model, navigation, DI, token selection subfeature
domain/ — Business logic (SwapInteractor) + domain models
api/ — Domain interfaces (SwapRepository)
models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.)
fee/ — Fee calculation package (see Fee Architecture below)
data/ — Repository implementations, Retrofit APIs, Moshi DTOs
Package naming: API = com.tangem.features.swap, Impl = com.tangem.feature.swap (singular feature, legacy inconsistency).
Build commands:
./gradlew :features:swap:impl:compileDebugKotlin
./gradlew :features:swap:api:compileDebugKotlin
./gradlew :features:swap:domain:compileDebugKotlin
./gradlew :features:swap:domain:test
./gradlew :features:swap:impl:detekt
Key Components
SwapComponent (API)
Entry point. Params requires userWalletId, optional cryptoCurrency, screenSource, currencyPosition (FROM/TO/ANY), and tangemPayInput.
File: features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt
DefaultSwapComponent (impl)
Decompose component. Creates SwapModel via getOrCreateModel(params).
Child navigation:
childStack(SwapRoute)—SwapRoute.Main,SwapRoute.Success,SwapRoute.SelectToken(isFromDirection), rendered viaChildrenwith fade animationSlotNavigation<Unit>— approval bottom sheet (GiveApprovalComponent)SlotNavigation<FeeSelectorConfig>— fee selector block
Injected factories: SwapFeeSelectorBlockComponent.Factory, GiveApprovalComponent.Factory, ChooseTokenComponent.Factory.
File: features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt
SwapModel (impl)
@ModelScoped, extends Model(). Central coordinator — ~2100 lines.
Key state:
dataStateStateFlow: MutableStateFlow<SwapProcessDataState>— reactive domain data (from/to tokens, pairs, providers, amounts, fees)uiState: SwapStateHolder by mutableStateOf()— Compose UI state built byStateBuilderfeeSelectorRepository: FeeSelectorRepository— inner class that implementsSwapFeeSelectorBlockComponent.ModelRepositoryExtended; wires the fee selector UI component toSwapInteractor.loadSwapFeeandSwapInteractor.applySwapFeestackNavigation: StackNavigation<SwapRoute>— stack navigation exposed fromSwapRouterapprovalSlotNavigation: SlotNavigation<Unit>— approval bottom sheet
File: features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt
Initialization flow (init block):
- Subscribes to
chooseTokenBridge.onCurrencyChosen→onTokenSelect(result) - Subscribes to
chooseTokenBridge.onClose→ pops slot navigation - Checks
ShouldShowStoriesUseCase→ pushesAppRoute.Storiesif first-time swap - Resolves user country for FCA restrictions
- Loads primary account status, initial currencies, and starts swap pair loading
Token selection flow:
- User taps FROM or TO card →
onSelectTokenClick(direction)pushesSwapRoute.SelectToken(isFromDirection)to stack - Stack creates
ChooseTokenComponentwith appropriate bridge (FROM or TO) ChooseTokenBridgecommunicates selection result via ChannelonTokenSelect(result)assigns selected token to FROM or TO based onisFromDirection
Swap execution flow:
onSwapClick()— validates state, checks approval, initiates transaction- If approval needed →
approvalSlotNavigation.activate(Unit) - On approval done → reloads quotes
- On swap success →
swapRouter.openScreen(SwapRoute.Success)
SwapProcessDataState (impl)
Data class holding the live domain state for the current swap session.
Key fields: fromSwapCurrencyStatus, toSwapCurrencyStatus, feePaidCryptoCurrency, pairs: List<SwapPairLeast>, selectedProvider, lastLoadedSwapStates: Map<SwapProvider, SwapState>, swapDataModel: SwapDataModel?, amount: String?, reduceBalanceBy.
getCurrentLoadedSwapState() — convenience to get lastLoadedSwapStates[selectedProvider] as? QuotesLoadedState.
File: features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt
StateBuilder (impl)
Pure transformation class. Takes UiActions + providers, builds SwapStateHolder from SwapProcessDataState.
Key methods: createInitialLoadingState, createQuotesLoadedState, createSuccessState, loadingPermissionState, updateSwapAmount, addNotification, dismissBottomSheet.
File: features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt
SwapRouter (impl)
Wraps AppRouter + StackNavigation<SwapRoute>. openScreen(SwapRoute) pushes/replaces stack entries. back() has special logic: SelectToken pops local stack, Success exits to the screen before SwapCrypto in the app stack, Main pops AppRouter. openTokenDetails() navigates to AppRoute.CurrencyDetails.
File: features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt
Token Selection Subfeature (impl)
Self-contained within choosetoken/ package:
ChooseTokenComponent— API withParams(bridge, settings, analyticsPayload)ChooseTokenBridge— Channel-based communication:onCurrencyChosen,onClose,onTokenSelected(legacy),onNewTokenAdded(legacy). HassettingsStateFlowfor dynamic settings.ChooseTokenComponent.Settings—SwapFrom(no market block) vsSwapTo(with market block)ChooseTokenResult— ContainsCryptoCurrencyStatus,AccountStatus,UserWalletDefaultChooseTokenComponent— Has its ownChooseTokenModeland optionalAddToPortfolioComponentbottom sheet slot
Domain Layer
SwapInteractor (interface)
File: features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt
All public methods:
getPair(from, to, filterProviderTypes)→Either<ExpressError, List<SwapPairLeast>>findProvidersForPair(from, to, pairs)→List<SwapProvider>findProvidersForPairWithCheck(from, to, pairs)→List<SwapProvider>(checks asset requirements/FCA)findBestQuote(from, to, providers, amount, reduceBalanceBy)→Map<SwapProvider, SwapState>(parallel per-provider)onSwap(from, to, provider, swapData, amount, includeFeeInAmount, fee, operationType, isTangemPayWithdrawal)→SwapTransactionStateloadSwapFee(provider, fromStatus, toStatus, amount, swapData, selectedFeeToken)→Either<GetFeeError, SwapFee>— unified fee entry point (see Fee Architecture)applySwapFee(state: QuotesLoadedState, fee: SwapFee)→QuotesLoadedState— patches balance checks without re-fetching quotesgetTokenBalance(token)→SwapAmountgetNativeToken(swapCurrencyStatus)→CryptoCurrencystoreSwapTransaction(...)— persists transaction for status tracking
SwapInteractorImpl (impl)
File: features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt
@Inject constructor with ~28 dependencies. Key injected components:
dexSwapFeeCalculator: DexSwapFeeCalculator— fee calculation for DEX/DEX_BRIDGEcexSwapFeeCalculator: CexSwapFeeCalculator— fee calculation for CEX
findBestQuote dispatches per-provider using supervisorScope + async:
ExchangeProviderType.DEX/DEX_BRIDGE→manageDex(...)ormanageDexSolana(...)ExchangeProviderType.CEX→manageCex(...)
For DEX (non-Solana): if allowance OK and balance sufficient → loadDexSwapDataNoFee(...) which fetches exchange data but sets feeState = NotEnough() transiently. Fee is applied later via applySwapFee.
onSwap dispatch:
- CEX →
onSwapCex(...)— fetches exchange data, then eithercreateAndSendGaslessTransactionUseCase(token fee) orsendTransactionUseCase(native fee) - DEX non-Solana →
onSwapDex(...)—createTransactionUseCasewithcreateDexTxExtras(..., gasLimit = fee.fee.getGasLimit()) - DEX Solana → compiled tx signed as-is;
feeis only used for analytics/UI
SwapTransferInteractor / SwapTransferInteractorImpl (domain)
Handles within-wallet transfers (same-wallet, same-account coin moves). shouldTransferInsteadOfSwap(from, to) detects same-wallet same-currency pairs. updateTransfer(from, to, amount) returns a SwapState.Transfer (not a quote). No fee calculation involved.
Files:
features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.ktfeatures/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt
Fee Architecture (post [REDACTED_TASK_KEY] refactor)
The fee subsystem was fully redesigned across three tickets ([REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY]). All legacy loadFeeForSwapTransaction, loadFeeForDex, getFeeForCex overloads have been removed. The current design:
Class Hierarchy
SwapInteractor.loadSwapFee() ← unified entry point (Phase 3)
├─ DEX/DEX_BRIDGE → DexSwapFeeCalculator.calculate() → DexFeeResult
│ ├─ Solana path: TransactionData.Compiled (no gas bump)
│ └─ EVM path: TransactionData.Uncompiled + patchEthGasLimitForSwap(DEX_PERCENTAGE=112)
│ └─ fallback: GetEthSpecificFeeUseCase on IllegalStateException
└─ CEX → CexSwapFeeCalculator.calculate() → CexFeeResult
├─ selectedFeeToken == null → EstimateFeeForGaslessTxUseCase (no gas bump)
├─ selectedFeeToken is Token → EstimateFeeForTokenUseCase (no gas bump)
└─ selectedFeeToken is Coin → EstimateFeeUseCase + patchEthGasLimitForSwap(SEND_PERCENTAGE=105)
SwapFeeFactory.from(transactionFeeResult, selectedFeeToken, otherNativeFee, feeBucket)
→ SwapFee (the single fee carrier used everywhere downstream)
SwapInteractor.applySwapFee(state, fee) ← patches QuotesLoadedState (Phase 4)
→ recomputes balanceStatus: SwapBalanceStatus (`Pending` / `Sufficient` / `FeeAdjustedAmount` / `InsufficientAmount` / `InsufficientFee`), currencyCheck, validationResult
Key Types
| Type | File | Purpose |
|---|---|---|
SwapFee |
domain/models/ui/SwapFee.kt |
Unified carrier: fee: Fee, transactionFeeResult: TransactionFeeResult, selectedFeeToken: CryptoCurrencyStatus, otherNativeFee: BigDecimal, feeBucket: FeeBucket |
FeeBucket |
domain/models/ui/FeeBucket.kt |
SLOW/MARKET/FAST/SUGGESTED/CUSTOM; toAnalyticsName() replaces legacy FeeType.getNameForAnalytics() |
TransactionFeeResult |
domain/fee/TransactionFeeResult.kt |
Sealed: Loaded(TransactionFee) for native, LoadedExtended(TransactionFeeExtended) for gasless/token |
DexFeeResult |
domain/fee/DexFeeResult.kt |
transactionFee, otherNativeFee, gas: BigInteger? |
CexFeeResult |
domain/fee/CexFeeResult.kt |
transactionFee: TransactionFeeResult |
DexSwapFeeCalculator |
domain/fee/DexSwapFeeCalculator.kt |
Solana vs EVM branching, 12% gas bump |
CexSwapFeeCalculator |
domain/fee/CexSwapFeeCalculator.kt |
gasless/token/native branching, 5% gas bump |
SwapFeeFactory |
domain/fee/SwapFeeFactory.kt |
fromLoaded, fromLoadedExtended, from (polymorphic) + selectFee for bucket picking |
PatchEthGasLimitForSwap |
domain/fee/PatchEthGasLimitForSwap.kt |
Multiplies ETH gas limit. DEX_PERCENTAGE=112, SEND_PERCENTAGE=105 |
DI for Fee Classes
Two PatchEthGasLimitForSwap instances with @Qualifier:
@SwapDexGasLimit→DEX_PERCENTAGE=112→ injected intoDexSwapFeeCalculator@SwapSendGasLimit→SEND_PERCENTAGE=105→ injected intoCexSwapFeeCalculator
Qualifiers: features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapFeeQualifiers.kt
Bindings: features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt
Fee Selector Wiring (SwapModel.FeeSelectorRepository)
SwapModel contains an inner class FeeSelectorRepository that implements SwapFeeSelectorBlockComponent.ModelRepositoryExtended. This is the bridge between the send-v2 fee selector UI component and the swap domain:
loadFeeExtended(selectedToken)→ callsswapInteractor.loadSwapFee(...), wraps result asTransactionFeeExtendedfor the fee selector blockloadFee()→ same path, extractsTransactionFeefrom theSwapFeeresultonResult(newState: FeeSelectorUM)→ when fee selector emitsContent, callsswapInteractor.applySwapFee(currentQuotesLoadedState, swapFee)and updatesdataState.lastLoadedSwapStates
DEX path requires a pre-fetched swapDataModel (populated by loadDexSwapDataNoFee). CEX passes swapData = null.
FeeItem → FeeBucket mapping lives at SwapModel.FeeItem.toFeeBucket() (line ~1921).
getSelectedSwapFee() (line ~1882) — reconstructs a SwapFee from feeSelectorRepository.state.value as FeeSelectorUM.Content.
otherNativeFee (DEX bridge)
ExpressTransactionModel.DEX.otherNativeFeeWei — present only for DEX_BRIDGE providers. Converted from Wei in DexSwapFeeCalculator.calculate() and propagated as DexFeeResult.otherNativeFee. Carried through to SwapFee.otherNativeFee.
applySwapFee uses fee.fee.amount.value + fee.otherNativeFee as the balance check amount. resolveOtherNativeFee() in SwapModel reads it from dataState.swapDataModel.transaction since FeeSelectorUM does not carry it.
Key Domain Models
SwapState(sealed) —QuotesLoadedState,Transfer,EmptyAmountState,SwapErrorQuotesLoadedStatecarriespreparedSwapConfigState: PreparedSwapConfigState(balance checks, fee state, includeFeeInAmount),permissionState,swapDataModel,currencyCheck,validationResult,minAdaValue,swapProvider
SwapProvider—providerId,name,type: ExchangeProviderType(DEX/CEX/DEX_BRIDGE), rates, slippage, TOS linksSwapPairLeast— from/toLeastTokenInfo(contractAddress + networkId) +providers: List<SwapProvider>SwapDataModel— quote result withtransaction: ExpressTransactionModel(sealed:DEX,CEX)SwapAmount—value: BigDecimal+decimals: IntTokenSwapInfo—tokenAmount: SwapAmount,amountFiat: BigDecimal,swapCurrencyStatus: SwapCurrencyStatus
File locations:
features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.ktfeatures/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.ktfeatures/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt
DI Modules
| Module | Scope | Purpose |
|---|---|---|
SwapFeatureModule |
Singleton | SwapComponent.Factory, SwapFeatureToggles |
SwapModelModule |
ModelComponent | SwapModel into model map |
SwapEntryModule |
Singleton + Model | SwapEntryComponent.Factory, SwapEntryModel |
ChooseTokenModule |
Singleton + Model | ChooseTokenComponent.Factory, ChooseTokenBridge.Factory, ChooseTokenModel |
SwapSingletonModule |
Singleton | AmountFormatter |
SwapDomainModule |
Singleton | DexSwapFeeCalculator, CexSwapFeeCalculator, two PatchEthGasLimitForSwap instances with qualifiers |
SwapDomainBindModule |
Singleton | SwapInteractor → SwapInteractorImpl, SwapTransferInteractor → SwapTransferInteractorImpl |
Analytics
SwapEvents sealed class hierarchy at features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt.
Fee tier analytics: FeeBucket.toAnalyticsName() → "Min"/"Normal"/"Max"/"Suggested"/"Custom". Maps to AnalyticsParam.FeeType.fromString(feeBucket.toAnalyticsName()). The legacy FeeType.getNameForAnalytics() extension was removed in Phase 5 of the fee redesign.
Navigation Summary
AppRouter (global)
└─ AppRoute.Swap → DefaultSwapComponent
├─ childStack(SwapRoute)
│ ├─ SwapRoute.Main → SwapMainChild (renders SwapScreen)
│ ├─ SwapRoute.Success → SwapSuccessChild (renders SwapSuccessScreen)
│ └─ SwapRoute.SelectToken → ChooseTokenComponent (FROM or TO bridge)
├─ SlotNavigation<Unit> (Approval)
│ └─ GiveApprovalComponent (bottom sheet)
└─ SlotNavigation<FeeSelectorConfig>
└─ SwapFeeSelectorBlockComponent (inline fee block)
UI Layer
SwapScreen— main swap composable (send card, receive card, swap button, provider, notifications, fee)SwapSuccessScreen— post-swap success with transaction detailsSwapScreenContent— layout withConstraintLayoutfor card positioningTransactionCard/TransactionCardEmpty— token cards with amount input- Token cards pass
TokenSelectionDirection.FROM/.TOtoonSelectTokenClick
Files: features/swap/impl/src/main/java/com/tangem/feature/swap/ui/
Testing
All domain-layer tests use JUnit 5 + MockK + Truth. Base class SwapInteractorImplTestBase wires all ~30 SwapInteractorImpl dependencies as relaxed mocks and exposes sut: SwapInteractorImpl via lazy. Tests extend it and stub only what they need.
Test files by topic:
SwapInteractorImplTestBase.kt— base class; also containsbuildSwapCurrencyStatus(...)and other buildersSwapInteractorImplLoadSwapFeeTest.kt— unifiedloadSwapFee(all strategy branches: DEX-EVM, DEX-Solana, DEX bridge, CEX gasless-native, CEX gasless-token, CEX explicit-token, null swapData, zero amount)SwapInteractorImplApplySwapFeeTest.kt—applySwapFeebalance/fee-state patchingSwapInteractorImplFindBestQuoteTest.kt— provider dispatch, balance checksSwapInteractorImplLoadDexSwapDataNoFeeTest.kt— DEX quote-load without feefee/DexSwapFeeCalculatorTest.kt— DEX calculator (Solana, EVM, gas fallback, bridge fee)fee/CexSwapFeeCalculatorTest.kt— CEX calculator (gasless, token, native)fee/SwapFeeFactoryTest.kt—SwapFeeFactorybucket selectionfee/PatchEthGasLimitForSwapTest.kt— gas limit bump mathtransfer/SwapTransferInteractorImplTest.kt— transfer detection and state buildingimpl/StateBuilderInitialStateTest.kt,StateBuilderPairsTest.kt— UI state construction
Gotchas
Fee state is transient on DEX. loadDexSwapDataNoFee returns a QuotesLoadedState with feeState = NotEnough() and isBalanceEnough = false. The real values are only set after the fee selector resolves and calls applySwapFee. Do not check preparedSwapConfigState.isBalanceEnough before the fee selector has emitted a FeeSelectorUM.Content state.
SwapFee is not carried in SwapProcessDataState. It is reconstructed from feeSelectorRepository.state.value via getSelectedSwapFee() at each call site (swap execution, analytics). otherNativeFee must be re-read from dataState.swapDataModel.transaction because FeeSelectorUM does not carry it.
DEX requires pre-fetched swapDataModel. FeeSelectorRepository.loadFeeExtended returns Left(UnknownError) when dataState.swapDataModel == null. This is by design: manageDex only calls loadDexSwapDataNoFee (which populates swapDataModel) when allowance is OK and balance is sufficient. If the user has insufficient balance or a pending approval, the fee selector will not load.
PatchEthGasLimitForSwap has two instances with different percentages. DEX uses 12%, CEX uses 5%. They are distinguished by @SwapDexGasLimit and @SwapSendGasLimit qualifiers. Passing the wrong qualifier to a calculator is a silent bug with no compile-time check.
Fee.Ethereum.TokenCurrency throws. PatchEthGasLimitForSwap.increaseEthGasLimitInNeeded calls error("handle in [REDACTED_TASK_KEY]") for TokenCurrency. This path must not be reached in production. The issue is tracked but not yet resolved.
Solana DEX fee is not patched. Unlike EVM, DexSwapFeeCalculator skips patchEthGasLimitForSwap for Solana paths. Also: if the compiled transaction exceeds SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES and the wallet is UserWallet.Cold, the calculator raises ExpressDataError.TooLargeSolanaTransactionError.
TransactionFeeResult sealed class is not a data class. Loaded(val fee: TransactionFee) and LoadedExtended(val fee: TransactionFeeExtended) use regular class, so structural equality does not hold. Use is-checks and field comparison in tests.
SwapInteractor interface vs SwapInteractorImpl. The interface exposes loadSwapFee and applySwapFee (the new unified API). The old loadFeeForSwapTransaction overloads (two overloads) and loadFeeForDex private method have been fully removed. Do not reference them in new code or tests.
Transfer mode vs swap mode. SwapTransferInteractor.shouldTransferInsteadOfSwap detects same-wallet same-currency pairs and returns true, causing the UI to show SwapState.Transfer instead of SwapState.QuotesLoadedState. No fee selector is shown in transfer mode.