tangem-app-android-audited/features/swap/CLAUDE.md
2026-06-16 17:10:00 +03:00

194 lines
No EOL
11 KiB
Markdown

# 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<SwapRoute>`; 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<Unit> → GiveApprovalComponent (bottom sheet)
└─ SlotNavigation<FeeSelectorConfig> → 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.