13 KiB
Swap V2 / Send-with-Swap Feature
This module implements Send-with-Swap (SvS): a send transaction where the sent token is swapped (CEX) to a different receive token at a destination address in one flow. The user picks a receive token, enters amounts (with Fixed/Float rate), enters a destination address (+ memo for memo-networks), reviews on Confirm, and sends.
There is no standalone token↔token swap UI in this module — that lives in
features/swap/(seefeatures/swap/CLAUDE.md). swap-v2 is the redesigned send-with-swap flow plus its shared amount/provider/notifications subscreens, built on the send-v2 subcomponents.
Module Structure
features/swap-v2/
api/ — com.tangem.features.swap.v2.api
SendWithSwapComponent (+ Params/Factory), SwapFeatureToggles,
SwapAmountUpdateTrigger, subcomponents/, choosetoken/
impl/ — com.tangem.features.swap.v2.impl (android-library + Hilt/kapt)
sendviaswap/ — SvS flow root, model, routes, confirm/, success/, analytics/
amount/ — swap amount screen (model, transformers, converters, entity, ui)
chooseprovider/— provider selector bottom sheet
choosetoken/ — receive-token / network selection
notifications/ — swap-specific notifications (price impact, express errors)
common/ — ConfirmData, SwapAlertFactory, SwapUtils, entities (ConfirmUM, SwapQuoteUM)
di/ — Hilt modules
Package naming: API = com.tangem.features.swap.v2.api, Impl = com.tangem.features.swap.v2.impl.
Consistent .v2 segment (unlike the legacy features/swap which uses feature.swap for impl).
Build commands:
./gradlew :features:swap-v2:impl:compileDebugKotlin
./gradlew :features:swap-v2:api:compileDebugKotlin
./gradlew :features:swap-v2:impl:testDebugUnitTest
./gradlew :features:swap-v2:impl:detekt
The SvS Flow (sendviaswap/)
Entry: SendWithSwapComponent (api) / DefaultSendWithSwapComponent (impl)
SendWithSwapComponent.Params:userWalletId,currency(the FROM token),callback.DefaultSendWithSwapComponent(impl/.../sendviaswap/DefaultSendWithSwapComponent.kt) owns an innerStackNavigation<SendWithSwapRoute>+InnerRouter, createsSendWithSwapModelviagetOrCreateModel, and achildStackrendering Amount/Destination/Confirm/Success.
Routes: SendWithSwapRoute
impl/.../sendviaswap/SendWithSwapRoute.kt — sealed Route, every entry has isEditMode: Boolean:
Amount(isEditMode)— implementsSwapAmountRouteDestination(isEditMode)— implements send-v2DestinationRouteConfirm(object,isEditMode = false)Success(object,isEditMode = false)
isEditMode distinguishes the linear forward flow (Amount → Destination → Confirm) from
re-editing a step from Confirm (showEditAmount/showEditDestination push the step with
isEditMode = true; onNextClick then pops back to Confirm instead of advancing).
Parent model: SendWithSwapModel
impl/.../sendviaswap/model/SendWithSwapModel.kt. @ModelScoped. Implements three child callbacks
(SwapAmountComponent.ModelCallback, SendDestinationComponent.ModelCallback,
SendWithSwapConfirmComponent.ModelCallback). Holds the aggregate state:
uiState: StateFlow<SendWithSwapUM>—{ amountUM, destinationUM, feeSelectorUM, confirmUM, navigationUM }primaryCryptoCurrencyStatusFlow,primaryFeePaidCurrencyStatusFlow,accountFlow,isAccountModeFlow,isBalanceHiddenFlow— read-only sources passed down to children as params.
Child→parent merge callbacks:
onAmountResult(amountUM)→uiState.copy(amountUM = …)onDestinationResult(destinationUM)→uiState.copy(destinationUM = …)onResult(sendWithSwapUM)→uiState.value = …(full replace; used by Confirm to publish its full state back up)onNavigationResult(navigationUM)→ drives the shared footer button/app-bar.
childStack subscription = the state-sync mechanism (READ THIS)
DefaultSendWithSwapComponent.init { childStack.subscribe(CREATE_DESTROY) { stack → … } }:
on every active-child change it pushes the parent's current snapshot into the newly-active child:
val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration) // synchronous!
componentScope.launch {
when (active) {
is SwapAmountComponent -> active.updateState(uiState.value.amountUM)
is SendDestinationComponent -> active.updateState(uiState.value.destinationUM) // screen
is SendWithSwapConfirmComponent -> {
if (isReturnedFromEdit) active.updateEditedState(uiState.value) // ← gated!
active.updateDestinationState(uiState.value.destinationUM) // [REDACTED_TASK_KEY] bypass
}
}
}
The gate must be true exactly when returning to a reused Confirm from an edit step — the Confirm route
itself has isEditMode = false, so the check reads the previous route via EditReturnTracker
(features/send/api/.../navigation/EditReturnTracker.kt, shared with Send and NFT Send). Gating on the
new active configuration instead made the re-push unreachable and Confirm kept stale amounts ([REDACTED_TASK_KEY]).
In the linear flow Confirm is re-created fresh from params.sendWithSwapUM, so no re-push is needed.
Confirm: SendWithSwapConfirmComponent / SendWithSwapConfirmModel
impl/.../sendviaswap/confirm/. The Confirm screen embeds read-only blocks reused from send-v2:
SwapAmountBlockComponent(swap-v2)SendDestinationBlockComponent(send-v2) — shows address + memo, click →showEditDestinationFeeSelectorBlockComponent(send-v2)SendNotificationsComponent(send-v2) +SwapNotificationsComponent(swap-v2)
SendWithSwapConfirmModel:
uiState: StateFlow<SendWithSwapUM>seeded fromparams.sendWithSwapUM.confirmData: ConfirmData(computed) — extractsenteredFromAmount/enteredToAmount,enteredDestination,enteredMemo,fee, statuses, quote, rateType, amountType, priceImpact fromuiState; this is what the transaction + notifications are built from.onFeeResult/onAmountResult/onDestinationResult— block callbacks copy intouiState.updateEditedState(sendWithSwapUM)— the edit-return re-push; copies ONLY the parent-owned fields (amountUM,destinationUM). Confirm-localconfirmUM/feeSelectorUMmust never be overwritten from the parent (a full replace killsblockClickEnableFlow— parent'sconfirmUMisEmpty).- Publishes its state up to the parent only after a successful send (
callback.onResult(uiState.value)). Until then, confirm-local changes (fee selection, confirmUM) live only in this model — the parent's copy is stale. - Sending:
SwapTransactionSender(CEX only; DEX/DEX_BRIDGE/ONRAMP rejected). Success →SendWithSwapConfirmSentStateTransformer+router.replaceAll(Success).
Success: SendWithSwapSuccessComponent
impl/.../sendviaswap/success/ — renders ConfirmUM.Success (tx date, explorer url, provider, swap data).
Amount screen (amount/)
SwapAmountComponent/SwapAmountModel(amount/model/SwapAmountModel.kt, ~big orchestrator).- State
SwapAmountUM(amount/entity/SwapAmountUM.kt):Empty(swapDirection)|ContentwithprimaryAmount/secondaryAmountfields,primary/secondaryCryptoCurrencyStatus,swapRateType: ExpressRateType(Fixed|Float),swapQuotes,selectedQuote: SwapQuoteUM,priceImpact. - Quotes are loaded periodically via a task scheduler and through
GetSwapQuoteUseCase. - Transformers (
amount/model/transformers/):SwapAmountValueChangeTransformer,SwapAmountSelectQuoteTransformer,SwapAmountSetQuotesTransformer,SwapAmountChangeAmountTypeTransformer,SwapAmount{Reduce*,Max,Paste,…}Transformer, applied viauiState.transformerUpdate(…). - Fixed vs Float:
SwapAmountType.Tomust useExpressRateType.Fixed(the float API can't target a to-amount);SwapAmountType.FromusesFloat. Provider filtering checksprovider.rateTypes.contains(rateType)before requesting a quote.
Choose provider / token, Notifications
chooseprovider/—SwapChooseProviderComponent/Model, bottom-sheet provider list (convertersSwapProviderListItemConverter,SwapProviderStateConverter).choosetoken/— receive-token + network selection (SwapChooseTokenNetworkModel, transformers).notifications/—SwapNotificationsComponent/Model, driven bySwapNotificationsUpdateTrigger/…Listener; produces price-impact / express-error / destination-tag-required notifications.
Reused send-v2 subcomponents (API boundary)
SvS consumes these features/send-v2/api contracts (impl injected via DI):
SendDestinationComponent.Factory— the navigable address/memo screen.SendDestinationBlockComponent.Factory— the read-only block on Confirm.FeeSelectorBlockComponent.Factory+FeeSelectorReloadTrigger.SendNotificationsComponent.Factory+SendNotificationsUpdateTrigger/…Listener.- Entities:
DestinationUM,FeeSelectorUM,NavigationUM,PredefinedValues.
The shared destination model is features/send-v2/.../subcomponents/destination/model/SendDestinationModel.kt.
Its updateState(destinationUM) does if (Content && isInitialized) _uiState.value = destinationUM
(StateFlow dedups equal values). saveResult() (push to the parent callback) runs on Next, on
auto-next, and on back only when !route.isEditMode.
DI modules (di/ and per-subpackage di/)
| Module | Scope | Provides |
|---|---|---|
SwapFeatureModules |
Singleton | SwapFeatureToggles |
SendWithSwapModule |
Singleton + Model | SendWithSwapComponent.Factory, SendWithSwapModel |
SwapAmountModule |
Singleton + Model | SwapAmountModel, SwapAmountUpdateTrigger/Listener, SwapAmountReduceTrigger/Listener |
SendWithSwapConfirmModule |
Model | SendWithSwapConfirmModel |
SwapChooseProviderModule |
Model | SwapChooseProviderModel |
SwapChooseTokenModule |
Singleton + Model | choose-token factories/model |
SwapNotificationsModule |
Singleton + Model | SwapNotificationsModel, SwapNotificationsUpdateTrigger/Listener |
Analytics
SendWithSwapAnalyticEvents(sendviaswap/analytics/) —ConfirmationScreenOpened,AmountScreenOpened,TransactionScreenOpened,OnSendClick,NoticeFixedRate/FloatRate,Error{InsufficientBalance,MinAmount,MaxAmount,ExpressQuote},HighPriceImpact,TradeTooLarge; category =CommonSendAnalyticEvents.SEND_CATEGORY.ExpressRateType.toAnalyticsRateType()maps rate.SwapAmountAnalyticEvents+SwapAmountAnalyticsSender(amount/analytics/) — provider selector events.
State-management patterns & gotchas
- Transformer pattern:
uiState.transformerUpdate(SomeTransformer(...)); transformers early-returnprevStateif not the expected subtype (as? Content ?: return prevState). - Three+ StateFlows hold the destination at once. The memo/address lives in: the navigable
Destination screen model (#A), the parent
SendWithSwapModel.uiState.destinationUM(#B), theSendWithSwapConfirmModel.uiState.destinationUM(#C), and the Confirm-embedded destination block model (#D, what Confirm actually displays). They are synced by snapshot copies (updateState,onResult,onDestinationResult) overStateFlow.value =(which dedups byequals), plus the block's self-feedinginit { uiState.onEach { onResult(it) } }. This is fragile — see [REDACTED_TASK_KEY] ("floating memo": an edit on #A intermittently fails to reach #D). Prefer a single source of truth when touching this area; do not assume anupdateStatere-push actually emits (equal value = no-op). - Edit-mode back does not persist. Leaving an edit step via the back arrow / system back skips
saveResult()(SendDestinationModel.configDestinationNavigation,if (!route.isEditMode)), so the parent keeps the pre-edit value. The footer "Continue"/"Next" button always persists. This is shared by regular Send + NFT Send + SvS. - The Confirm re-push gate reads the previous route. The reused-Confirm
updateStatein the childStack subscription is gated onEditReturnTracker.onRouteActivated(...)(send api), which reports whether the route active before the new one was an edit route. Gating on the new active configuration (Confirm.isEditMode— alwaysfalse) silently disables the re-push ([REDACTED_TASK_KEY]). The tracker must be called synchronously in the subscribe callback, not inside a launched coroutine. - CEX-only.
SwapTransactionSenderrejects DEX/DEX_BRIDGE/ONRAMP. Destination address for CEX is only known after exchange-data, so confirm notifications passdestinationAddress = nullfor the send-notifications path.
Testing
JUnit 5 + MockK + Turbine + Truth (see project .claude/rules/unit-testing.md). Feature-model tests
build the heavy graph with relaxed mocks and a single StandardTestDispatcher; drive with
advanceUntilIdle() and model.onDestroy(). For SvS state-sync regressions, prefer parent-model
(SendWithSwapModel) tests asserting that an edit propagated through onDestinationResult is the value
that uiState.destinationUM ends up holding across an edit→confirm round trip.