# Swap Feature Token-to-token exchange. Users pick FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute the swap. ## Module map ``` features/swap/ api/ — Public contracts (SwapComponent, SwapFeatureToggles) impl/ — UI, SwapModel, navigation, DI, token-selection subfeature domain/ — SwapInteractor + domain models api/ — Domain interfaces (SwapRepository) models/ — SwapPair, SwapProvider, SwapState, … fee/ — Fee calculation (see Fee Architecture) data/ — Repository impls, Retrofit APIs, Moshi DTOs ``` **Package quirk:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature` — legacy inconsistency, follow it). **Build / test:** ```bash ./gradlew :features:swap:impl:compileDebugKotlin ./gradlew :features:swap:domain:test ./gradlew :features:swap:impl:detekt ``` ## Where to start reading | Symbol | Role | Path | |---|---|---| | `SwapComponent` | API entry point; `Params(userWalletId, cryptoCurrency?, screenSource, currencyPosition, tangemPayInput, toCryptoCurrency?)` | `api/.../features/swap/SwapComponent.kt` | | `DefaultSwapComponent` | Decompose component; creates `SwapModel`, owns the child stack + slots | `impl/.../feature/swap/DefaultSwapComponent.kt` | | `SwapModel` | Central coordinator (~2100 lines). State holder + fee-selector bridge | `impl/.../feature/swap/model/SwapModel.kt` | | `SwapProcessDataState` | Live domain state for the session (tokens, pairs, providers, `swapDataModel`, amount) | `impl/.../feature/swap/model/SwapProcessDataState.kt` | | `StateBuilder` | Pure builder: `SwapProcessDataState` → `SwapStateHolder` (Compose UI state) | `impl/.../feature/swap/ui/StateBuilder.kt` | | `SwapRouter` | Wraps `AppRouter` + `StackNavigation`; custom `back()` per route | `impl/.../feature/swap/router/SwapRoute.kt` | | `SwapInteractor` | Domain API; `loadSwapFee` / `applySwapFee` are the unified fee entry points | `domain/.../feature/swap/domain/SwapInteractor.kt` | | `SwapInteractorImpl` | ~28 deps; `findBestQuote` dispatches per-provider via `supervisorScope + async` | `domain/.../feature/swap/domain/SwapInteractorImpl.kt` | `toCryptoCurrency` pre-selects the **TO** (receive) token, but only if it is already present in the user's crypto portfolio — resolved by `InitialCurrenciesResolver` (matched by token identity / `isSameTokenAs`, preferring the FROM account's instance). If the token isn't in the wallet, the TO slot stays empty. Used by Send-with-Swap's "Swap token" notice when a pair is available only in the regular Swap flow. `SwapModel` state worth knowing: `dataStateStateFlow` (reactive domain data) and `uiState: SwapStateHolder` (Compose state); the inner `FeeSelectorRepository` wires the send-v2 fee selector to `SwapInteractor.loadSwapFee`/`applySwapFee`. ## Navigation ``` AppRoute.Swap → DefaultSwapComponent ├─ childStack(SwapRoute) │ ├─ Main → SwapScreen │ ├─ Success → SwapSuccessScreen │ └─ SelectToken → ChooseTokenComponent (FROM or TO bridge) ├─ SlotNavigation → GiveApprovalComponent (bottom sheet) └─ SlotNavigation → SwapFeeSelectorBlockComponent (inline) ``` Injected factories on `DefaultSwapComponent`: `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`. Token selection: tapping FROM/TO pushes `SwapRoute.SelectToken(isFromDirection)`; the `ChooseTokenComponent` returns its result over a `ChooseTokenBridge` Channel (`onCurrencyChosen` → `onTokenSelect`). Swap execution: `onSwapClick()` → (approval slot if needed) → on success `SwapRoute.Success`. ## Token selection subfeature (`impl/choosetoken/`) Self-contained. `ChooseTokenComponent` (with `ChooseTokenModel`) communicates via `ChooseTokenBridge` (Channel-based: `onCurrencyChosen`, `onClose`). `Settings` is `SwapFrom` (no market block) vs `SwapTo` (with market block). Result type `ChooseTokenResult` carries `CryptoCurrencyStatus`, `AccountStatus`, `UserWallet`. ## Fee Architecture Single unified entry: `SwapInteractor.loadSwapFee()` → strategy calculator → `SwapFee` carrier; `applySwapFee()` patches the loaded quote without re-fetching. ``` loadSwapFee() ├─ DEX/DEX_BRIDGE → DexSwapFeeCalculator.calculate() → DexFeeResult │ ├─ Solana: TransactionData.Compiled (NO gas bump) │ └─ EVM: Uncompiled + patchEthGasLimitForSwap(DEX=112%) │ └─ fallback GetEthSpecificFeeUseCase on IllegalStateException └─ CEX → CexSwapFeeCalculator.calculate() → CexFeeResult ├─ feeToken == null → EstimateFeeForGaslessTxUseCase (no bump) ├─ feeToken Token → EstimateFeeForTokenUseCase (no bump) └─ feeToken Coin → EstimateFeeUseCase + patchEthGasLimitForSwap(SEND=105%) SwapFeeFactory.from(...) → SwapFee (the single fee carrier downstream) applySwapFee(state, fee) → patches QuotesLoadedState.balanceStatus / currencyCheck / validationResult ``` Fee types (all under `domain/fee/` unless noted) — open the file for fields: `SwapFee` (`domain/models/ui/`, the carrier), `FeeBucket` (`domain/models/ui/`, `SLOW/MARKET/FAST/SUGGESTED/CUSTOM` + `toAnalyticsName()`), `TransactionFeeResult` (sealed: `Loaded` native / `LoadedExtended` gasless+token), `DexFeeResult`, `CexFeeResult`, `DexSwapFeeCalculator`, `CexSwapFeeCalculator`, `SwapFeeFactory`, `PatchEthGasLimitForSwap`. **Fee selector wiring** (`SwapModel.FeeSelectorRepository`, implements `SwapFeeSelectorBlockComponent.ModelRepositoryExtended`): `loadFeeExtended`/`loadFee` call `loadSwapFee`; `onResult(FeeSelectorUM)` calls `applySwapFee` on `Content` and updates `dataState.lastLoadedSwapStates`. `getSelectedSwapFee()` reconstructs a `SwapFee` from the selector's current `Content` state. `FeeItem.toFeeBucket()` maps UI → bucket. **`otherNativeFee` (DEX bridge only):** `ExpressTransactionModel.DEX.otherNativeFeeWei` (present only for `DEX_BRIDGE`) → converted in `DexSwapFeeCalculator` → `SwapFee.otherNativeFee`. `applySwapFee` checks balance against `fee.amount.value + otherNativeFee`; `resolveOtherNativeFee()` re-reads it from `dataState.swapDataModel.transaction`. ## Key domain models `SwapState` (sealed, `domain/models/ui/SwapState.kt`): `QuotesLoadedState`, `Transfer`, `EmptyAmountState`, `SwapError`. `QuotesLoadedState` carries `preparedSwapConfigState` (balance/fee checks), `permissionState`, `swapDataModel`, `currencyCheck`, `validationResult`, `swapProvider`. Other types — open the file: `SwapProvider` (has `type: ExchangeProviderType` = DEX/CEX/DEX_BRIDGE), `SwapPairLeast`, `SwapDataModel` (`transaction: ExpressTransactionModel` sealed DEX/CEX, `domain/models/domain/`), `SwapAmount`, `TokenSwapInfo`. Transfers: `SwapTransferInteractor` handles same-wallet same-currency moves — `shouldTransferInsteadOfSwap` → `SwapState.Transfer` (no quote, no fee). ## DI modules (where bindings live) | Module | Provides | |---|---| | `SwapFeatureModule` | `SwapComponent.Factory`, `SwapFeatureToggles` | | `SwapModelModule` / `SwapEntryModule` / `ChooseTokenModule` | Models into the model map + their factories | | `SwapDomainModule` | `DexSwapFeeCalculator`, `CexSwapFeeCalculator`, the two qualified `PatchEthGasLimitForSwap` | | `SwapDomainBindModule` | `SwapInteractor`/`SwapTransferInteractor` → impls | Two `PatchEthGasLimitForSwap` instances are distinguished by `@SwapDexGasLimit` (112%) vs `@SwapSendGasLimit` (105%) — qualifiers in `domain/di/SwapFeeQualifiers.kt`. ## UI & analytics UI under `impl/.../feature/swap/ui/`: `SwapScreen` (main), `SwapSuccessScreen`, `SwapScreenContent` (ConstraintLayout card positioning), `TransactionCard`. Cards pass `TokenSelectionDirection.FROM`/`.TO` to `onSelectTokenClick`. Analytics: `SwapEvents` sealed hierarchy (`impl/.../analytics/SwapEvents.kt`). Fee tier name comes from `FeeBucket.toAnalyticsName()` (`Min/Normal/Max/Suggested/Custom`) → `AnalyticsParam.FeeType.fromString(...)`. ## Testing Domain tests: JUnit 5 + MockK + Truth. Base `SwapInteractorImplTestBase` wires all ~30 deps as relaxed mocks, exposes `sut` lazily, and holds builders (`buildSwapCurrencyStatus`, …) — extend it and stub only what you need. Test files mirror topics: `…LoadSwapFeeTest`, `…ApplySwapFeeTest`, `…FindBestQuoteTest`, `…LoadDexSwapDataNoFeeTest`, `fee/{Dex,Cex}SwapFeeCalculatorTest`, `fee/SwapFeeFactoryTest`, `fee/PatchEthGasLimitForSwapTest`, `transfer/SwapTransferInteractorImplTest`, `impl/StateBuilder*Test`. ## Gotchas **Fee state is transient on DEX.** `loadDexSwapDataNoFee` returns a `QuotesLoadedState` with `feeState = NotEnough()` and `isBalanceEnough = false`. Real values are only set after the fee selector resolves and `applySwapFee` runs. Do not check `preparedSwapConfigState.isBalanceEnough` before the fee selector has emitted `FeeSelectorUM.Content`. **`SwapFee` is not stored 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 a pre-fetched `swapDataModel`.** `FeeSelectorRepository.loadFeeExtended` returns `Left(UnknownError)` when `dataState.swapDataModel == null`. By design: `manageDex` only calls `loadDexSwapDataNoFee` (which populates it) when allowance is OK and balance is sufficient. With insufficient balance or pending approval, the fee selector will not load. **Two `PatchEthGasLimitForSwap` instances, different percentages.** DEX 12%, CEX 5%, selected by `@SwapDexGasLimit` / `@SwapSendGasLimit`. Passing the wrong qualifier is a silent bug — 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 (issue tracked, not yet resolved). **Solana DEX fee is not patched.** `DexSwapFeeCalculator` skips `patchEthGasLimitForSwap` on Solana paths. Also: a compiled tx exceeding `SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES` on a `UserWallet.Cold` raises `ExpressDataError.TooLargeSolanaTransactionError`. **`TransactionFeeResult` is not a data class.** `Loaded` / `LoadedExtended` are regular classes, so structural equality does not hold — use `is`-checks + field comparison in tests. **Don't reference the removed fee API.** The unified API is `loadSwapFee` / `applySwapFee`. The old `loadFeeForSwapTransaction` overloads, `loadFeeForDex`, `getFeeForCex`, and `FeeType.getNameForAnalytics()` were removed — do not reintroduce them in new code or tests. **Transfer mode vs swap mode.** `shouldTransferInsteadOfSwap` returns `true` for same-wallet same-currency pairs → UI shows `SwapState.Transfer`, not `QuotesLoadedState`, and no fee selector.