Swap to New Token#2872
Conversation
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>
…eout The swap picker's first paint is gated on fully-stubbed responses (account-balances, paths, top50), so it resolves well under Playwright's default 5s expect timeout. The explicit 15s/10s waits were over-provisioned. Verified at retries=0: swap.test.ts (12/12), sendPayment.test.ts (37/37), and the four blockaid swap tests (12/12 across 3 repeats). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@piyalbasu thanks for reviewing! Yeah, you are right that those could be separate PRs, I will keep that in mind for a next big feature like this so we ship it in smaller pieces 🚢 |
The two hidden mirror spans (used to measure the amount input's content width) carried identical static inline styles. Extract them into a `&__hidden-mirror` class, appended to the existing font class so width measurement is unaffected, and keep visibility:hidden so offsetWidth stays measurable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Send and Swap each defined a byte-identical AVAILABLE_BALANCE_FONT_SIZES, and the swap rate row hand-rolled the same length-stepped lookup. Extract a shared popup/components/amount/fontScale module: a generic fitFontSizePx(value, scale) helper plus the single AVAILABLE_BALANCE_FONT_SIZES scale and its getAvailableBalanceFontSizePx accessor. Send, Swap, and SwapRateRow now import from it; SwapRateRow keeps its own (distinct) scale but shares the lookup. Behavior-preserving. Added fontScale tests; tsc clean; swap/send unit suites pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
priceValueUsd and dstPriceValueUsd computed the same formatAmount(roundUsdValue(price * cleanAmount(amount))) expression. Extract a single formatUsdValue(price, amount) helper for both and drop the no-op template-literal wrapper that was on priceValueUsd. Behavior-preserving; tsc clean, SwapAmount unit suites pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The swap and add-token lookups each defined their own MAX_ASSETS_TO_SCAN = 10 (the /scan-asset-bulk batch size). Export it once from popup/helpers/blockaid next to scanAssetBulk and import it in both hooks so the batch size has a single source of truth. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The canonical zero amount ("0") and zero USD amount ("0.00") were repeated
throughout SwapAmount. Introduce DEFAULT_AMOUNT / DEFAULT_AMOUNT_USD constants
and use them for the amount/amountUsd occurrences. Scoped to this file's amount
literals; the fee/balance/display-string zeros in the helpers are left as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
schemaValidatedAssetList is called once per enabled asset list, and each call re-fetched the identical SEP-0042 schema (3 fetches when opening the Swap-to list or searching, once per list). The browser HTTP-cached it, but the requests were still issued every time. Fetch the schema once per session and reuse it; a rejected fetch is not cached so a transient failure still retries. Added a test asserting a single fetch across multiple validations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@piyalbasu good catch, and exactly right — Fixed in c40fd69: the schema is now fetched once and memoized, so all list validations in a session reuse it — a single request instead of one per list (and it's shared across the Swap-to / search / Add-a-token flows too, not just deduped per open). A failed fetch isn't cached, so a transient error still retries on the next attempt. |
The percentage buttons compute a fraction of the crypto available balance, but in fiat mode the result was stored as a rounded USD amount and the swap committed amountUsd / price — so 50% in fiat mode resolved to a slightly different crypto amount than 50% in crypto mode. Make the crypto amount the committed source of truth in both display modes: the percentage sets the crypto amount directly (the fiat field mirrors it, rounded, for display), the fiat input keeps the crypto amount in sync as the user types, and continue/validate use it. A tapped percentage now resolves to the same crypto amount regardless of display mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping Review ran a second horizonGetBestPath and overwrote the displayed destinationAmount, so the receive amount ticked slightly right as the user confirmed. Build the review transaction from the path + destination amount the live quote already surfaced (in Redux), so the reviewed amount matches exactly what was on screen. Drift by submit time stays bounded by destMin (slippage) and the quote-expiry recovery; fall back to a fresh path lookup only when no live quote exists yet. Verified: swap E2E (review + submit + quote-expiry recovery) and SwapAmount unit suites pass; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In fiat mode the sell card's crypto line, the too-high check, and the live 'You receive' quote all recomputed the crypto amount as amountUsd / price. Since the fiat field only shows dollars to the cent, that derived amount was slightly smaller than the actual crypto amount — so after tapping Max in crypto mode and switching to fiat, the crypto shown (and quoted) was a hair under the balance, and tapping Max again in fiat couldn't fix it. Use the crypto amount (already the committed source of truth, kept in sync by the fiat input) directly for the sell display, the too-high check, and the live quote. Removes the now-redundant priceValue. The receive quote and the reviewed amount now match what's shown in either display mode. Verified: swap E2E + SwapAmount unit suites pass; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The amount screen (fiat mode) shows the entered dollars (amountUsd), while the review computed USD from the crypto amount x price (priceValueUsd) — so the two could disagree by ~1 cent (crypto-amount rounding or a price refresh between setting the amount and reviewing). Pass the amount screen's displayed figure to the review: amountUsd in fiat mode, priceValueUsd in crypto mode. The crypto amount (the actual swap) was already identical on both screens. Also tidied a now-stale comment (the pipeline reads the crypto amount, not amountUsd, since the source-of-truth change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the swap fixes: the send amount screen tracked which field the user last edited (editedInputType) and, in fiat mode, committed amountUsd / price and displayed effectiveTokenAmount — so a tapped percentage or a typed fiat amount resolved to a slightly different crypto amount than crypto mode, and the review showed a different fiat figure than the amount screen. Make the crypto amount authoritative in both display modes: - Percentage buttons always set the crypto amount (the fiat field mirrors it, rounded, for display). - The fiat input keeps the crypto amount in sync as the user types. - Continue/validate/too-high and the fiat crypto-line all read the crypto amount directly; removed editedInputType / effectiveTokenAmount / priceValue. - The review shows the same fiat figure as the amount screen (entered dollars in fiat mode, computed USD in crypto mode). Verified: sendPayment E2E (37/37, incl. amount-preserved-across-toggle) and SendAmount unit suites pass; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The schema-fetch memoization made schemaValidatedAssetList reuse the first fetched schema across the whole test file, so the fetch-fails and validation-error cases in searchAsset.test.js got a stale cached schema instead of their own mock. Clear the cache in beforeEach (as token-list.schema.test.ts already does). Verified: full test:ci passes (1342/1342). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a destination token was picked before its Blockaid scan landed and the recovery scan then failed (transient error → scanAssetBulk returns null), the hook bailed without dispatching, so securityLevel stayed undefined and the review showed the token as clean with a normal confirm — the malicious/unable warning never reached the user. Drop the `|| !scan` early-return: a missing scan now flows through getAssetSecurityLevel, which maps absent data to UNABLE_TO_SCAN on a Blockaid-enabled network (matching the picker's unscanned-row handling), so the review shows the 'couldn't be scanned' banner + 'Confirm anyway' gate. A successful scan still flags malicious/suspicious; a superseded scan still writes nothing. Adds useSwapDestinationScan unit tests (fail-open, flagged, superseded). Verified: test:ci passes (1345). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JakeUrban the points 1 & 4 above have been fixed on both Swap and Send flows, you should see stable crypto and fiat amounts now. About the point 3, as I mentioned earlier we are already loading data ahead of time which makes it very hard to repro the long loading time on first Swap picker open. I had to delete and reinstall the extension several times to be able to repro it once, and I also had to immediately open the picker right after opening the pop-up to be able to repro it. Then on the rare times I was able to repro it it happened just on the very first rendering, after that the result is cached and you should always see the popular tokens rendering immediately on following renders. So from my testing it appears that it is very unlikely that a regular user will see it unless they open the Swap picker blazing fast right after updating the app version for the first time, and, in case they see it, they will only see it once because the next time there should be a cache in place already. I'm thinking we could release it like this and revisit it in case we start seeing long loading times happening more often. How does that sound? Thanks! |
The swap flow scans destination tokens via the /scan-asset-bulk endpoint, whose
response is { data: { results: { [assetId]: ... } } }. stubScanAssetSafe caught
that endpoint (via **/scan-asset**) but returned the single-asset shape, so the
recovery hook read results[id] as undefined. Previously that failed open
silently; now (correctly) it resolves to UNABLE_TO_SCAN, which made the 'Swap
shows no warning when scan confirms tokens are safe' test see a token warning.
Have the stub return the bulk shape (keyed by the requested asset_ids) for the
bulk endpoint and the single shape otherwise, so a safe scan resolves to SAFE.
Verified: blockaid swap E2E (safe/malicious/suspicious/unable) + swap.test.ts
pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4aab554 to
d568194
Compare
Update the design doc to match what shipped: crypto amount as the single source of truth in both display modes (§2.5), the fail-closed destination verdict recovery (§3.9), the shared fontScale module (§3.3), the new useSwapDestinationScan/fontScale test suites (§3.12), and corrected facts/line-refs (priceValue→priceValueUsd, line count, SlideupModal refs, MIN_TRENDING_VOLUME7D location, CONFIRM_AMOUNT step). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>


Summary
Enables swapping from a held token to any Stellar classic asset in a single flow — token discovery (held + popular + free-text search), Blockaid coverage on non-held destinations, and an atomic
changeTrust + pathPaymentStrictSendwhen a trustline is needed.This is the browser-extension counterpart to the mobile feature. See the reference PR and its design doc:
extension/specs/swap-to-new-token-design.md— kept in sync with what shipped; mirrors the mobile design doc.Scope: 189 files changed, ~16.3k LOC across the branch:
src/(+@shared) — ~7.7k lines (47%)Most of the large diffs are net-new files (new picker sections, new hooks, new sheets) plus tests written alongside them, and shared UI components that Send and the account-home balances list now reuse.
What's new
useSwapTokenLookup— owns destination-token discovery: held balances, popular tokens (volume7d ∩ verified lists), and free-text search via stellar.expert. Closes a security gap: every non-held destination is Blockaid-scanned before it can be selected.SwapAssetpicker, parameterized byselectionType— one picker drives both the Sell and Receive sides, replacing the old single-list picker.SwapPickerSections— the labeled sections (Your tokens / Popular / Verified / Unverified), the(i)info sheets, and the empty / Soroban / fallback states.SwapAmountrewrite — built on the new sharedAmountCard+PercentageButtons, with a Sell/Receive layout, a swap-direction chevron, crypto⇄fiat toggle, and a debounced live "You receive" quote that updates as you type.buildChangeTrustOperationis extracted and prepended so a new-token swap is a singlechangeTrust + pathPaymentStrictSendXDR (fee split total-across-ops); Blockaid scans the combined XDR for defence in depth.XlmReserveSheet— a pre-flight when the account lacks XLM headroom for the new 0.5 XLM trustline reserve; offers a "Swap for 0.5 XLM" affordance, a copy-address fallback, and a "why do I need XLM?" link.BlockaidBanneracross Add-a-token, Sign Transaction, Sign Message, ChangeTrust, Grant Access, and Swap; verdict-colored token badges (amber = suspicious, red = malicious); a scan-on-select recovery path so a token picked before its verdict lands still gets assessed.op_under_dest_min/op_too_few_offersat submit are classified and surfaced as a toast, and the flow recovers to the amount screen with a fresh quote instead of dead-ending.AmountCard,PercentageButtons,AssetListRow, andBalanceRoware now shared by Send, Swap, and the account-home balances list, removing duplicated markup.Search results strategy
When the user types a search term, the picker renders three labeled, mutually-exclusive sections — chosen over a flat "Results" list so users can visually distinguish levels of trust:
(i)icon opening a sheet explaining what the verified-token lists are.(i)icon for the unverified caveat.G…issuer addresses surface the assets that account issues via stellar.expert's fuzzy?search=.C…contract pastes fall under a Soroban-specific empty state explaining the flow is classic-only for now — Soroban contract tokens are filtered out at every stage.Caching & pre-warm
Popular ("top tokens") lists are cached in two layers, keyed per network:
chrome.storage.local(background-owned; the popup messages the background, matching every other cache in the codebase), with a 30-minute staleness window. Because it survives popup close, reopening the extension paints Popular straight from disk with no spinner.cacheslice — the session working copy, read first on each open.On open the picker reads Redux → disk → network in freshness order, using whichever layer is still fresh. Search/discover results repaint instantly from the last cached result and revalidate silently in the background (SWR-style), so re-typing a term never flashes a spinner.
Pre-warm.
useSwapTopTokensPrewarmmounts on the account/home screen and — deferred ~1s past first paint, mainnet-only, aborting on unmount — populates both cache layers when the persisted entry is stale or absent. So by the time the user opens Swap the Popular list is usually already warm (the analog of mobile's tab-mount pre-warm). It costs at most one trending request per staleness window.Blockaid asset scans are deliberately not persisted. Unlike Popular tokens, per-token Blockaid verdicts stay in an in-memory (module-scoped) cache and are re-scanned each session. Persisting verdicts to disk would carry a cache-poisoning risk — a tampered entry could mark a malicious token "safe" — so we keep them in memory only.
Defence in depth at review. The scan caches are a first-paint UX optimization. The review step still issues a fresh
scanTransactionagainst the built XDR every time, so transaction-level signals are always evaluated live per confirmation.Graceful fallback (stellar.expert unreachable)
Held-to-held swaps keep working even when stellar.expert is down:
Path-finding is unaffected, since it uses Horizon strict-send paths, not stellar.expert.
Notes for reviewers
transactionSubmissionslice (destinationTokenDetails, best-path, cache slots), not a separate store.SlideupModal/LoadingBackgroundpattern rather than native bottom sheets; toasts usesonner.SwapAmountbusiness logic is split into focused hooks + helpers (useSwapLiveQuote,useSwapQuoteExpiry,useSwapDestinationScan,getSwapDerivedData, …), mirroring the mobileSwapScreendecomposition, so the component stays presentation-focused.Telemetry
Eight new events instrument the flow (screen-view events like
swap: amountalready existed on master):swap: picker openedside(source/destination),source(cta/dropdown)swap: source selectedtokenCode,tokenIssuer,sourceswap: destination selectedtokenCode,tokenIssuer,requiresTrustline,sourceswap: direction toggledswap: xlm reserve shownswap: quote expiredop_under_dest_min/op_too_few_offers)sourceToken,destToken,sourceAmount,destAmount,allowedSlippage,resultCodeswap: trustline addedtokenCode,tokenIssuerswap: successsourceToken,destToken,sourceAmount,destAmount,allowedSlippageswap: trustline addedandswap: successfire post-confirmation — once the transaction actually settles, not at review time — mirroring mobile.Shared Blockaid banner
Now we have a single reusable
BlockaidBannercomponent that renders in every flow that surfaces a security verdict — SAFE renders nothing, suspicious = amber, malicious = red — replacing the old per-flow warning components. The screenshot below covers all of them: Grant access / connect, Add a token, Sign message, Sign transaction, Send review, and Swap review.Transaction details sheet
The shared transaction-details sheet (operation-by-operation breakdown), is now available in both the Swap and Send review flows and aligned with mobile UI.
Testing
Manually exercised in Chrome on the popup and sidebar. Full unit suite passing locally; the Playwright E2E swap spec was updated for the new flow (12/12), and the broader Blockaid / add-token / send E2E specs were updated to the new shared components and pass.
Videos
Swapping to a new token
swap-to-new-token-hh.mov
Swapping to a held token
swap-to-held-token-hh.mov
Swapping with not enough XLM for reserves
swap-xlm-reserve-sheet.mov
Using the search input
swap-using-the-search-box.mov
Quote expiration warning + retry
swap-quote-expired-retry.mov
Swapping to/from a token with no price
swap-with-no-prices.mov
Blockaid warning
swap-blockaid-warning.mov
Graceful degradation when stellar.expert is down
swap-stellar-expert-down.mov
Amount and path errors
swap-amount-and-path-errors.mov