Conversation
There was a problem hiding this comment.
Pull request overview
Restores the pre-Fusion “swap direction” (You pay ⇄ You receive) switch button on the Swap screen and rewires the token/amount swapping behavior to work with the Fusion SwapContext, including the zero-balance guard and “clear amounts to force a fresh quote” behavior.
Changes:
- Reintroduced the circular token-switch button UI between the “You pay” and “You receive” sections, including focus-driven hide/show behavior.
- Implemented
handleToggleTokensto swapfromToken/toToken, clear amounts, and block switching when the resulting pay-token would have zero swappable balance. - Adjusted the separator rendering to match the previous split-separator design when the switch button is visible.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Coverage report ✅2/2 packages passed thresholds 🟢 @avalabs/core-mobile
🟢 @avalabs/k2-alpine
Artifacts and threshold sources
Source run: Mobile PR |
B0Y3R-AVA
left a comment
There was a problem hiding this comment.
Adversarial review pass (verified against the branch source, not just the diff). Two pre-merge items below; everything else checked out clean — dep arrays, getSwappableBalance typing, e2e swap_vertical_icon wiring, getButtonBackgroundColor usage, and the FadeIn/FadeOut + LinearTransition mix are all fine.
| const handleToggleTokens = useCallback(() => { | ||
| // Can't pay with a token the user holds none of. | ||
| if (toToken && getSwappableBalance(toToken) === 0n) { | ||
| setValidationError(fusionErrors.noDestinationToken(toToken.symbol)) |
There was a problem hiding this comment.
Routing the blocked-toggle feedback through validationError wedges the current (unchanged, still-valid) swap: activeError = validationError ?? quoteError feeds canSwap, and noDestinationToken is a blocking FusionQuoteError (no isWarning), so Next disables and the pay field flags invalid even though nothing about the on-screen direction changed.
Nothing clears it in place either — validateInputs and the token-compatibility effect only rerun when the amount or a token changes, so the error sticks until the user edits something unrelated.
Suggestion: surface "can't reverse — no X balance" via a toast/snackbar (or similar transient UI) instead of validationError, so the still-valid current swap isn't gated.
There was a problem hiding this comment.
Good catch — fixed in 1ed0d01. The zero-balance guard now surfaces the message via showSnackbar (same copy, reusing fusionErrors.noDestinationToken(...).message) instead of setValidationError, so the current direction's swap state (Next button, pay-field validity) is untouched and nothing lingers.
| exiting={FadeOut} | ||
| style={{ | ||
| position: 'absolute', | ||
| top: -20, |
There was a problem hiding this comment.
Android tap-target concern: this 40×40 TouchableOpacity (CircularButton, no hitSlop) sits at top: -20 inside a host view whose natural layout height is just the 1px separator row. RN on Android doesn't hit-test touches outside the parent's bounds, and zIndex: 100 only affects paint order, not touch — so most (possibly all) of the button may be untappable on Android even though it renders correctly.
Worth verifying on an Android device before merge. If it repros, give the host view real bounds (or render the button in a sibling with actual height) rather than relying on overflow paint.
There was a problem hiding this comment.
Checked both in the RN source and on a device:
- The "no hit-testing outside parent bounds" behavior is the old architecture's. This app is on RN 0.85 / Fabric, and Fabric's Android
TouchTargetHelper.kthit-tests using overflow insets: touches outside a view's bounds are only rejected foroverflow: hidden/clipChildrenparents (lines ~191-198), otherwiseisTouchPointInViewWithOverflowInsetaccepts points within the child-overflow region (lines ~272-279). None of the button's ancestors here setoverflow: hidden(the card wrappers that do are siblings), andzIndex: 100also puts this subtree first in hit-test order. - Verified on an Android device: the full 40×40 button is tappable, including the half overlapping the "You pay" card.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
packages/core-mobile/app/new/features/swap/screens/SwapScreen.tsx:1788
- The toggle button is currently enabled whenever
!isSwapping, buttoTokencan be cleared (incompatible networks / same-token guard), which makes toggling a no-op at best and surprising at worst. Disable the button when either token is missing.
disabled={isSwapping}
packages/core-mobile/app/new/features/swap/screens/SwapScreen.tsx:1783
- This icon-only
CircularButtonlacks an accessible label. Add atitle(or accessibility label, depending on the component API) so screen readers can announce what the control does.
<CircularButton
testID="swap_vertical_icon"
backgroundColor={swapButtonBackgroundColor}
style={{
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/core-mobile/app/new/features/swap/hooks/useMaxSwapAmount/index.ts:61
useHoldMaxWhileRecalculatingmutateslastKnownRef.currentduring render. In React 18+/19 (and RN Strict Mode), render can be re-run or abandoned, so render-time ref writes are side effects and can lead to held values being out of sync. Move the ref updates into an effect, and compute the held value purely (guarded by the current tokenKey) during render.
const lastKnownRef = useRef<{ tokenKey: string; value: bigint }>()
const tokenKey = fromToken ? getTokenKey(fromToken) : undefined
if (lastKnownRef.current && lastKnownRef.current.tokenKey !== tokenKey) {
lastKnownRef.current = undefined
}
if (max !== undefined && tokenKey !== undefined) {
lastKnownRef.current = { tokenKey, value: max }
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/core-mobile/app/new/features/swap/hooks/useMaxSwapAmount/index.ts:227
isMaxLoadingis computed even when the pre-quote prerequisites aren’t met (e.g.,toToken/addresses missing). In those statesadditiveFeeForMaxstaysundefined, socomputeIsMaxLoadingreturnstrueandTokenInputWidgetwill keep the Max button mounted + disabled indefinitely, even though nothing is actually “loading” until the user selects the missing inputs. GateisMaxLoadingon the same prerequisites asusePreQuoteso Max hides (or behaves as before) when the route can’t be estimated yet.
const hasEstimationError =
(!!feeEstimationError && !isFeeEstimationFetching) || preQuoteFailed
const isMaxLoading = computeIsMaxLoading({
fromToken,
Description
Ticket: CP-14844
CircularButton+SwapVerticalicon centered over the separator between the "You pay" / "You receive" cards, split separators, hidden while the "You pay" input is focused,testID="swap_vertical_icon"kept for existing e2e locators).SwapContext(the oldsetDestination(SwapSide.SELL)API no longer exists): it swapsfromToken/toTokenand clears the amounts so the reversed pair fetches a fresh quote — rates aren't symmetric across services, so the old receive-amount is intentionally not carried over (web carries it over; pre-Fusion mobile cleared it, we follow mobile).fusionErrors.noDestinationTokenerror when the current "to" token has no swappable balance. The old implementation checked thetokensWithZeroBalancelist (C-Chain/Solana only); this usesgetSwappableBalance(toToken) === 0n, which covers all networks and correctly excludes staked/locked P/X-chain balances.Stale-quote safety: clearing the amount makes
useQuoteStreamingdropbestQuote/allQuotesimmediately, so Next can't be pressed with a quote from the previous direction.Screenshots/Videos
ScreenRecording_07-23-2026.11-45-40_1.MP4
Testing
iOS: 9398
Android: 9399
Dev Testing
QA Testing
Checklist
Please check all that apply (if applicable)
🤖 Generated with Claude Code