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

11 KiB

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:

./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: SwapProcessDataStateSwapStateHolder (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 (onCurrencyChosenonTokenSelect). 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 DexSwapFeeCalculatorSwapFee.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 — shouldTransferInsteadOfSwapSwapState.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.