tangem-app-android-audited/features/swap/CLAUDE.md
2026-05-22 17:12:20 +04:00

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 via Children with fade animation
  • SlotNavigation<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 by StateBuilder
  • feeSelectorRepository: FeeSelectorRepository — inner class that implements SwapFeeSelectorBlockComponent.ModelRepositoryExtended; wires the fee selector UI component to SwapInteractor.loadSwapFee and SwapInteractor.applySwapFee
  • stackNavigation: StackNavigation<SwapRoute> — stack navigation exposed from SwapRouter
  • approvalSlotNavigation: SlotNavigation<Unit> — approval bottom sheet

File: features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt

Initialization flow (init block):

  1. Subscribes to chooseTokenBridge.onCurrencyChosenonTokenSelect(result)
  2. Subscribes to chooseTokenBridge.onClose → pops slot navigation
  3. Checks ShouldShowStoriesUseCase → pushes AppRoute.Stories if first-time swap
  4. Resolves user country for FCA restrictions
  5. Loads primary account status, initial currencies, and starts swap pair loading

Token selection flow:

  1. User taps FROM or TO card → onSelectTokenClick(direction) pushes SwapRoute.SelectToken(isFromDirection) to stack
  2. Stack creates ChooseTokenComponent with appropriate bridge (FROM or TO)
  3. ChooseTokenBridge communicates selection result via Channel
  4. onTokenSelect(result) assigns selected token to FROM or TO based on isFromDirection

Swap execution flow:

  1. onSwapClick() — validates state, checks approval, initiates transaction
  2. If approval needed → approvalSlotNavigation.activate(Unit)
  3. On approval done → reloads quotes
  4. 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 with Params(bridge, settings, analyticsPayload)
  • ChooseTokenBridge — Channel-based communication: onCurrencyChosen, onClose, onTokenSelected (legacy), onNewTokenAdded (legacy). Has settingsStateFlow for dynamic settings.
  • ChooseTokenComponent.SettingsSwapFrom (no market block) vs SwapTo (with market block)
  • ChooseTokenResult — Contains CryptoCurrencyStatus, AccountStatus, UserWallet
  • DefaultChooseTokenComponent — Has its own ChooseTokenModel and optional AddToPortfolioComponent bottom 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)SwapTransactionState
  • loadSwapFee(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 quotes
  • getTokenBalance(token)SwapAmount
  • getNativeToken(swapCurrencyStatus)CryptoCurrency
  • storeSwapTransaction(...) — 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_BRIDGE
  • cexSwapFeeCalculator: CexSwapFeeCalculator — fee calculation for CEX

findBestQuote dispatches per-provider using supervisorScope + async:

  • ExchangeProviderType.DEX / DEX_BRIDGEmanageDex(...) or manageDexSolana(...)
  • ExchangeProviderType.CEXmanageCex(...)

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 either createAndSendGaslessTransactionUseCase (token fee) or sendTransactionUseCase (native fee)
  • DEX non-Solana → onSwapDex(...)createTransactionUseCase with createDexTxExtras(..., gasLimit = fee.fee.getGasLimit())
  • DEX Solana → compiled tx signed as-is; fee is 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.kt
  • features/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:

  • @SwapDexGasLimitDEX_PERCENTAGE=112 → injected into DexSwapFeeCalculator
  • @SwapSendGasLimitSEND_PERCENTAGE=105 → injected into CexSwapFeeCalculator

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) → calls swapInteractor.loadSwapFee(...), wraps result as TransactionFeeExtended for the fee selector block
  • loadFee() → same path, extracts TransactionFee from the SwapFee result
  • onResult(newState: FeeSelectorUM) → when fee selector emits Content, calls swapInteractor.applySwapFee(currentQuotesLoadedState, swapFee) and updates dataState.lastLoadedSwapStates

DEX path requires a pre-fetched swapDataModel (populated by loadDexSwapDataNoFee). CEX passes swapData = null.

FeeItemFeeBucket 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, SwapError
    • QuotesLoadedState carries preparedSwapConfigState: PreparedSwapConfigState (balance checks, fee state, includeFeeInAmount), permissionState, swapDataModel, currencyCheck, validationResult, minAdaValue, swapProvider
  • SwapProviderproviderId, name, type: ExchangeProviderType (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
  • SwapPairLeast — from/to LeastTokenInfo (contractAddress + networkId) + providers: List<SwapProvider>
  • SwapDataModel — quote result with transaction: ExpressTransactionModel (sealed: DEX, CEX)
  • SwapAmountvalue: BigDecimal + decimals: Int
  • TokenSwapInfotokenAmount: SwapAmount, amountFiat: BigDecimal, swapCurrencyStatus: SwapCurrencyStatus

File locations:

  • features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt
  • features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt
  • features/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 SwapInteractorSwapInteractorImpl, SwapTransferInteractorSwapTransferInteractorImpl

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 details
  • SwapScreenContent — layout with ConstraintLayout for card positioning
  • TransactionCard / TransactionCardEmpty — token cards with amount input
  • Token cards pass TokenSelectionDirection.FROM / .TO to onSelectTokenClick

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 contains buildSwapCurrencyStatus(...) and other builders
  • SwapInteractorImplLoadSwapFeeTest.kt — unified loadSwapFee (all strategy branches: DEX-EVM, DEX-Solana, DEX bridge, CEX gasless-native, CEX gasless-token, CEX explicit-token, null swapData, zero amount)
  • SwapInteractorImplApplySwapFeeTest.ktapplySwapFee balance/fee-state patching
  • SwapInteractorImplFindBestQuoteTest.kt — provider dispatch, balance checks
  • SwapInteractorImplLoadDexSwapDataNoFeeTest.kt — DEX quote-load without fee
  • fee/DexSwapFeeCalculatorTest.kt — DEX calculator (Solana, EVM, gas fallback, bridge fee)
  • fee/CexSwapFeeCalculatorTest.kt — CEX calculator (gasless, token, native)
  • fee/SwapFeeFactoryTest.ktSwapFeeFactory bucket selection
  • fee/PatchEthGasLimitForSwapTest.kt — gas limit bump math
  • transfer/SwapTransferInteractorImplTest.kt — transfer detection and state building
  • impl/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.