Skip to content

[WIP] Swap to New Token#2872

Draft
CassioMG wants to merge 108 commits into
masterfrom
feature/swap-to-new-token
Draft

[WIP] Swap to New Token#2872
CassioMG wants to merge 108 commits into
masterfrom
feature/swap-to-new-token

Conversation

@CassioMG

Copy link
Copy Markdown
Contributor

No description provided.

CassioMG and others added 30 commits June 23, 2026 22:25
Addresses PR review: the clipboard "Paste" button was removed from all
extension designs since a programmatic clipboard read needs an extra
clipboardRead manifest permission + user opt-in (not worth it on web).
Manual paste into the search field still works. Documents the omission
as a mobile-vs-extension difference in §2.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses PR review: the extension supports custom networks (mobile does
not), and verified-token lists + stellar.expert exist only for Mainnet/
Testnet. Document that on custom networks the picker gracefully omits the
Popular section and search degrades to held-only (held-to-held swaps still
work via the network's Horizon). Covered in §3.1 + §2.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses PR review: isNew was vague. requiresTrustline reads as it is
used (when true, bundle a changeTrust op). Renamed everywhere including
the telemetry payload field, to be kept in sync with freighter-mobile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…wns width

InputWidthContext and InputWidthProvider were only used to pass width state to SendAmount,
which has been replaced by AmountCard owning width internally (per design §3.3).
Grep confirmed zero remaining consumers; removed provider wrapper and deleted context file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pular tokens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n results

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p picker

Idle = Your tokens + Popular (volume7d ∩ verified, held filtered out);
search = Your tokens + Verified + Unverified. Classic-only filter with
hadSorobanMatches empty-state, pick-time bulk Blockaid scan of non-held
candidates, AbortController cancellation + CODE:ISSUER dedupe, and a
held-only graceful fallback on stellar.expert errors / custom networks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Create two small informational SlideupModal components for the swap token
picker: VerifiedTokenInfoSheet (explains asset list verification) and
UnverifiedTokenInfoSheet (cautions about unverified tokens). Each has a
single "Got it" dismiss button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ack states)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a reusable `buildChangeTrustOperation({ assetCode, assetIssuer, isRemove, sdk })`
helper exported from getManageAssetXDR.ts. `getManageAssetXDR` now delegates to it
internally with no behavior change for the existing add/remove trustline flow.
Task E2 (swap builder) will reuse this helper to prepend a changeTrust op.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s + direction chevron

Replace the bespoke inline input / AssetTile form body with two AmountCards
(sell = editable, receive = read-only fed by destinationAmount), PercentageButtons
wired to the existing percentage/Max math, and a direction chevron that emits
swapDirectionToggled and swaps saveAsset/saveDestinationAsset. Crypto/fiat toggle,
path-finding trigger, and fee/slippage footer row are fully preserved. Slippage
default was already "2" (Phase A2). Seams left for F8 (CTA state machine) and
F9 (selectionType / destination-details / telemetry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t to SwapAmount

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…emetry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CassioMG and others added 30 commits June 26, 2026 15:10
A new-trustline swap is two operations (changeTrust + pathPaymentStrictSend),
but the amount screen's default fee was the single recommended base fee. It
was then split across both ops at build time, so each op paid only half the
recommended fee — underpriced under congestion — and the preflight + spendable
deducted a 1-op fee for a 2-op transaction.

getSwapTotalFee now scales the recommended default by op count (each op pays
the recommended fee), while a user-set custom fee is still treated as the
total and split per op at build time. Because this single fee value feeds the
displayed fee, the simulation, the built transaction, the reserve preflight,
and the spendable balance, all of them now reflect the true 2-op total.
Mirrors mobile's recommendedFee × ops default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The XlmReserveSheet's "Swap for 0.5 XLM" button was inert: canSwapForReserve
was hardcoded false and the handler only set the receive token to XLM,
leaving the user to figure out the sell side and amount themselves.

Implements the mobile flow:
- canSwapForReserve is true when the current source is already a non-XLM
  classic token, or the account holds one (pickBestNonXlmClassicCanonical
  selects the largest such balance); otherwise the button stays hidden.
- The handler sets the receive side to XLM (clearing the now-stale trustline
  details). If the source is reused, it pre-fills the amount needed to
  receive ~0.5 XLM via horizonGetBestReceivePath, capped to the sell token's
  spendable so the user never lands on an insufficient-balance state. If it
  falls back to a different sell token, the amount resets to 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the frozen quote no longer clears on-chain at submit time, Horizon
returns op_under_dest_min / op_too_few_offers. The shared submit flow routed
that to the terminal SubmitFail screen, so the user lost the swap and had to
start over.

The submit reducer now flags isSwapQuoteExpired on rejection (via the existing
quote-expiry op-code helper; a no-op for sends, which never produce these
codes). The Swap view detects the flag, emits the swapQuoteExpired metric with
the result codes, clears the error status, and routes back to the amount screen
— where SwapAmount's live-quote effect auto-refetches a fresh quote and the
quote-expired notice is shown until the user retries. No SubmitFail flash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The best-non-XLM-classic-balance lookup added for the reserve-recovery action
used useMemo, but it sits below the component's early returns — a hook there
violates the rules of hooks. Replace it with a plain computation; the
filter/sort over held balances is cheap and needs no memoization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The amount-screen CTA only disabled on a missing asset, a zero amount, or an
over-balance amount. It stayed clickable when the live quote found no route
(the submit would then fail) and when a non-XLM swap had no XLM to pay the
network fee.

- Extracts the CTA logic into a pure getSwapCtaState state machine (mirrors
  mobile's useSwapCtaState) with explicit labels per blocking state.
- Adds an isLiveQuoteLoading flag so "no path" is distinguished from "quote
  still loading" — the CTA only disables once a quote settles with no route,
  avoiding flicker between keystrokes (2.5).
- Adds an insufficient-XLM-for-fees gate for non-XLM sources, since their fee
  comes from the separate XLM balance the swap amount never touches (2.4).

No change for 2.6: the extension's roundUsdValue floors to cents, so a fiat
Max can't round-trip above spendable — the overspend mobile fixed never
occurs here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The swap picker only surfaced the "Soroban tokens aren't supported, try a
Classic token" hint when the search actually returned soroban records
(hadSorobanMatches). A user pasting a bare contract id that stellar.expert
returns nothing for instead saw the generic "No tokens match" message.

Now the hint also shows when the search term itself is a contract id, matching
mobile's empty-state condition (hadSorobanMatches || isContractId(searchTerm)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The classic-only filter ran a SAC check (isAssetSac) on contract records that
could never pass: search results map a contract token's issuer to its contract
id, which isAssetSac can't validate against a classic issuer, so the branch was
dead — the record was always dropped by the issuer-is-contract-id check anyway.

Replaces it with a single contract-id test on issuer/contract, matching
mobile's isSorobanRecord (isContractId(record.asset)). Behavior is unchanged:
classic CODE-ISSUER records are kept (including SAC-backed assets, which
stellar.expert returns in classic form), and custom Soroban tokens + LPs are
dropped. Drops the now-unused isAssetSac import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The review screen evaluated only the transaction (XDR) scan: a malicious or
suspicious destination token was scanned at pick time and stored on
destinationTokenDetails.securityLevel, but ReviewTransaction never read it, so
swapping into a flagged token raised no warning at review.

mergeSecurityLevels now rolls the destination token verdict together with the
transaction verdict (most-severe wins; SAFE/absent never escalate). The merged
level drives the existing warning + "Confirm anyway" soft gate — matching
mobile, which warns rather than hard-blocks. A flagged token also shows its own
banner (the transaction-scan banner stays gated on the tx verdict so a
token-only warning never opens an empty scan pane). Unable-to-scan tokens
surface the same way (4.2/4.5).

Send is unaffected: it passes no token level, so the merge reduces to the
transaction verdict and the gate behaves exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the swap security model: the sell (source) token's verdict — read
from its held balance's blockaidData via getAssetSecurityLevel — is now folded
into the same merged review gate as the destination and transaction verdicts,
and shown in its own banner. A flagged sell token now warns and requires
"Confirm anyway", and the review surfaces source and destination verdicts
independently (§4.3/§4.7).

XLM sources are skipped (never scanned by Blockaid). Send is unaffected: it
passes no source level.

4.4 (selectable-before-scan) needs no change — an unscanned token yields no
verdict, so the merge simply doesn't escalate. 4.8 is already satisfied: the
destination candidate set is scanned in one chunked bulk request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
swapTrustlineAdded fired at review time, the moment the user tapped Confirm —
before the swap was even submitted — so it counted intended, not completed,
trustlines. And swaps had no dedicated success event (they reused the Send
payment-success metric).

Both now fire from useSubmitTxData only after the transaction actually settles,
matching mobile: a new swapSuccess event (source/dest tokens, amounts,
slippage) plus swapTrustlineAdded when the confirmed transaction included the
changeTrust op. Sends are unchanged — they still emit sendPaymentSuccess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-serve

Re-entering the swap picker within a popup session re-ran the full idle
pipeline (popular fetch + verified-list split + bulk scan) behind a spinner,
and a transient stellar.expert/Blockaid failure dropped Popular entirely to the
held-only fallback.

Adds a module-scoped per-network cache of the last successful idle (no search
term) result, mirroring mobile's trendingMemoryCacheByNetwork:
- On idle re-entry the cached result repaints immediately and the pipeline
  revalidates silently — no spinner flash (§1.10).
- On a transient fetch failure the cached result is served instead of dropping
  to held-only; held-only remains the fallback only when nothing is cached
  (§5.4, in-memory).

In-memory only (dies on popup close); cross-session disk persistence (§5.3) is
a separate concern. Exposes resetSwapIdleCacheForTests for test isolation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stellar.expert trending request is the slowest part of the idle swap
pipeline, and its cache lived only in memory (Redux + the module cache), so it
re-ran on every popup reopen. The verified lists, by contrast, are cheap static
CDN JSON and change rarely, so they stay in-memory for now.

Adds a chrome.storage.local-backed cache of the top-tokens list, keyed per
network with the same 30-min staleness window as the Redux cache. The idle path
now layers Redux (in-session) → disk (cross-session) → network, so reopening the
extension paints Popular from disk instead of re-fetching. Best-effort: storage
errors degrade to a network fetch.

Blockaid scans stay ephemeral (tamper risk), and persisted trending is still
bulk-scanned + intersected with the verified set, so a tampered entry can't
masquerade as verified or skip the scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With top-tokens persisted to disk, repeat Swap entries already paint instantly,
but the first entry per staleness window still pays the slow trending request.
Pre-warming primes that cache from the home screen so even the first Swap entry
is instant.

Adds useSwapTopTokensPrewarm, mounted on the Account view. It defers past first
paint, runs mainnet-only, and only fetches when the persisted cache is missing
or stale — so it costs at most one trending request per 30-min window, not one
per home render. On a fresh fetch it updates both the Redux and disk caches; all
errors are swallowed (the Swap pipeline still fetches on open). The core
prewarmTopTokens logic is extracted and unit-tested independently of the timer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lers

The top-tokens disk cache was writing chrome.storage.local directly from the
popup via dataStorageAccess — the only popup-side direct storage writer in the
codebase. Every other cache (asset icons, domains, memo-required accounts, etc.)
is owned by the background and reached from the popup via messages, so this
aligns the swap cache with that convention.

- New GET_CACHED_SWAP_TOP_TOKENS / CACHE_SWAP_TOP_TOKENS service types +
  message-request shapes (tokens kept opaque as unknown[] so @shared doesn't
  depend on the popup's trending-asset type).
- Background handlers own the write (keyed per network under
  CACHED_SWAP_TOP_TOKENS_ID, stamping updatedAt), registered in
  popupMessageListener and mirroring the asset-icon cache.
- @shared/api/internal wrappers send the messages; the popup helper now calls
  those and applies the 30-min staleness window, with no direct storage access.

Behavior is unchanged — same per-network 30-min cache; only the access path now
follows the established background-owned pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On first entry the sell card grabbed focus and showed a white "0" with a
blinking cursor, while the bottom CTA correctly read the missing-token state but
was disabled — a dead end.

- The sell card no longer auto-focuses until both tokens are picked (the source
  defaults to XLM, so the card stays unfocused on entry) and shows a gray "0"
  placeholder (empty input) until an amount is entered; redux keeps the
  canonical "0".
- The missing-token CTA is now enabled and acts as a shortcut to the picker for
  the missing side, preferring the sell token when both are missing.
- Renames the CTA from "Select an asset" to "Select a token" — we're
  standardizing on "token" over "asset" in user-facing copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The swap-from search matched balances by code, issuer, and contractId, so a
pasted Stellar Asset Contract (SAC) address for a held classic/native token
found nothing and fell through to the "Soroban contract tokens aren't
supported" empty state — even though the destination picker resolves the same
SAC correctly via stellar.expert.

Add a SAC branch to the source filter: when the term is a contract id, derive
each held token's SAC address (pure, no extra API call) and match. Extracted
the per-balance predicate into matchesSwapFromSearch for unit testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker list shows a warning badge on flagged tokens, but the badge
vanished once a token was selected: the "You sell" / "You receive" AmountCards
never received a securityLevel, so AssetIcon rendered without the ScamAssetIcon
overlay. Pass the source token's Blockaid verdict and the destination's
pick-time verdict (DestinationTokenDetails.securityLevel) to their cards so the
badge persists through the amount screen, matching the picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Typing in the destination picker switched the list into search mode
immediately — hiding the idle "Popular" tokens — but the previous lookup's
held tokens lingered in state through the 300ms debounce, so stale "Your
tokens" stayed visible until the new results arrived. Track a search-pending
flag from the keystroke until the lookup settles and render the loader during
that window, so every result clears at once. The source filter is synchronous,
so it's unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Rate row split its width 50/50, so the short "Rate" label wasted half the
row while a long rate ("1 ABC ≈ 0.0000001 XYZ") was squeezed into the other
half and clipped. Scope a --rate row modifier that sizes the label to its
content plus a 20px gap and lets the value fill the rest, and step the value's
font-size down by length so it stays uncropped — mirroring the swap amount
screen's font scale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When swapping to a token the account doesn't hold yet, the icon cache has no
entry for it, so the submit screen resolved its destination icon to undefined
and showed a broken image during "Swapping"/"Swapped!" — even though the picker
and review screen rendered it fine. Fall back to the iconUrl carried on the
picked token's details (the same source those screens use) so the icon is
present throughout the whole swap flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The search-pending loader was cleared by an effect keyed on lookupState.state,
but the idle in-memory cache dispatches FETCH_DATA_SUCCESS while the state is
already SUCCESS (a SUCCESS→SUCCESS repaint). That transition never changes the
enum value, so the effect didn't re-run and the loader stuck forever when
typing or clearing the search box.

Clear the flag off the lookup promise instead: await lookupFetchData in the
formik submit and reset isSearchPending in a finally, guarded by a monotonic
sequence so a superseded (debounced/aborted) lookup can't unblock a newer
pending search. The brittle lookupState-value effect is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…m picker

The "Soroban contract tokens aren't supported" empty state only makes sense on
the swap-to picker — you can only swap FROM a token you already hold. The
shared SwapPickerSections had no notion of which side it was on, so pasting a
contract id into the swap-from search showed the Soroban message instead of
"No tokens match …". Thread an isDestination prop and gate the Soroban branch
on it. Also wrap long pasted addresses in the empty card (overflow-wrap) so a
56-char address no longer bleeds past the screen edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Once both tokens were picked, the CTA correctly read "Enter an amount" but was
hard-disabled while the user had spendable balance, so there was no affordance
to start typing. Mirror mobile: the enter state is enabled and tapping it
focuses the sell amount input. A new availableBalanceIsZero guard keeps the CTA
disabled with "Insufficient balance" when the spendable balance is zero, so the
enable doesn't give a dead-end button to an unfunded account.

AmountCard gains an optional amountInputRef so the parent can focus the input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two glitches appeared on the first crypto→fiat toggle and self-healed after an
edit:
- The fiat input carried onFocus={e => e.target.select()} (the crypto input
  did not), so the autofocus that accompanies the toggle highlighted the whole
  sell amount. Removed it to match the crypto input.
- Each input's width is measured from a hidden mirror span, but the spans only
  existed inside their own inputType branch — so the fiat width was still 0 on
  the first toggle and the value was clipped to the fallback width for a frame.
  Render both mirror spans unconditionally so the inactive width is measured
  before the toggle.

Also size the read-only receive amount from its own value instead of reusing
the sell-derived font-size class, which could mis-size it on toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…icons

The review screen could stack up to three Blockaid banners (transaction scan +
source token + destination token). Collapse to exactly one, chosen by priority
to mirror mobile's useReviewSecuritySummary: the transaction-scan verdict
outranks the token verdict, and among tokens the worse level wins (the
destination breaks a tie). The merged verdict that drives the Confirm-anyway
gate and the expandable Blockaid pane is unchanged.

Also surface the warning badge over the review token icons — SendAsset and
SendDestination hardcoded isSuspicious={false}, so a flagged token showed no
badge unlike the picker and amount screen. They now receive each token's
verdict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping the "This will add a trustline" banner opened the trustline info sheet
on top of the still-painted review sheet, leaving the review summary visible as
a ghost behind it. Gate the review MultiPaneSlider on !isOnTrustlinePane (the
same guard the action buttons already use) so the review body is hidden while
the sheet is up and restored when it closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Quote has expired" message was a fixed banner pinned above the cards,
permanently taking layout space. Fire it as a sonner toast instead (the same
component the Discover flow uses) — it floats over the screen, auto-dismisses,
and is swipe-dismissible. A stable toast id dedupes the in-screen and
submit-recovery triggers into one toast. Detection is unchanged (op-level
op_under_dest_min / op_too_few_offers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match Figma node 8641-33470: add the lilac plus-circle badge and a circular
close button in the header, a token-specific body ("To receive {{tokenCode}},
your wallet needs a trustline on Stellar.") with the "Why do I need XLM?" link
inline, and a reserve card showing the XLM icon + "0.5 XLM required" and a
get-it-back explanation. The CTAs become a filled primary "Swap for 0.5 XLM"
and an outlined "Copy my wallet address" (no icon); the standalone help button
is gone.

The "Why do I need XLM?" link pointed at an empty URL (opened a blank tab);
point it at the same help article mobile uses. New copy added to en + pt with
parity coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The single-banner choice keyed on txSecurityLevel being non-null, but on
mainnet an absent transaction scan yields UNABLE_TO_SCAN (non-null). So a swap
to a token flagged malicious at pick time would show the soft "proceed with
caution" tx banner and suppress the malicious-token warning — both a downgrade
and a divergence from mobile, which ranks unable-to-scan lowest.

Pick the banner by the full mobile cascade (tx malicious/suspicious > token
malicious/suspicious > any unable-to-scan) so a flagged token always outranks a
merely-unscannable transaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The read-only receive card's inputType flips from the sell card's toggle
without its own value changing, so a value that straddles a font-size bucket
could keep a stale width and clip after toggling. Add the font-size class to
the width-measuring effects' deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant