Skip to content

[WIP] Swap to New Token#2872

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

[WIP] Swap to New Token#2872
CassioMG wants to merge 89 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 25, 2026 20:34
BalanceRow forwarded an empty assetIcons map when callers passed a single
iconUrl (the swap picker's held list), so AssetIcon's isFetchingAssetIcons
(isEmpty(assetIcons) && !isXlm) stayed true and every non-XLM held row spun
forever (XLM was exempt). Synthesize a one-entry icons map from iconUrl when
assetIcons is empty; the account-home path (real non-empty map) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuilds the verified/unverified token info bottom sheets to the Figma
spec: a colored icon badge + circular close on top, an 18px title, the
secondary body copy, and a full-width dismiss button.

Extracts a shared InfoBottomSheet (badge variant + close + title + body
+ CTA) so the two sheets stay consistent; the modal-agnostic
InfoSheetContent can be reused by the trustline pane next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The banner becomes a compact lilac pill (alert-square icon + label +
chevron) instead of the oversized primary Notification. The info sheet
is rebuilt on the shared InfoSheetContent (lilac badge + plus icon, X
close, title, body, "Got it" CTA) with the token-specific reserve copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The source picker only filtered once the search term exceeded two
characters, so 1-2 letter token codes never matched until a third
character was typed. The empty-term case is already handled separately,
so always filter when a term is present — matching the Swap-to search.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Sort the held "Your tokens" list by descending fiat value via the
  shared sortBalancesByValue helper, matching the account-home list.
- Show Classic assets only: exclude liquidity-pool shares (already) and
  custom Soroban contract tokens (have a contractId), since swaps run
  over the Classic path. The stellar.expert search already drops non-SAC
  Soroban results via the isClassic filter.
- Never filter "Your tokens" by hiddenAssets, so a held token stays
  visible even when it is the asset already selected on the other side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useNetworkFees expresses recommendedFee in XLM (the fetched value is
converted from stroops), but the initial state and the error fallback
used the raw stroop BASE_FEE ("100"). On the Swap amount screen that
rendered as "100 XLM" and was subtracted from the XLM available balance
until feeStats resolved ~0.5s later, causing both labels to jump.

Seed and fall back to stroopToXlm(BASE_FEE) ("0.00001 XLM") instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The receive picker derived its icon solely from the account icons map,
which only holds logos for held tokens. A non-held destination token
(picked from search/popular) therefore rendered with no logo even though
the picker list showed it fine. Fall back to the icon URL captured on
the picked token (destinationTokenDetails.iconUrl) when the map has none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-held token can never be the source (swaps run between held/classic
assets). When the receive side holds a non-held token, the direction
toggle now drops it back to "(+) Select" and moves the held source into
the receive slot instead of swapping it into the sell slot. Held-or-empty
pairs still swap positions normally.

To support the resulting empty source slot, the sell card, available
balance, simulate params and CTA now tolerate an unset source asset
(mirroring the existing destination handling). The amount is reset
whenever the source token changes, since it was denominated in the old
source token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Render the trustline body via <Trans> so "0.5 XLM will be reserved" is
emphasized inline (medium weight, brighter text) per Figma, using a named
<bold> component to keep the sentence a single translatable key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match Figma: the empty-result message (e.g. a search that matched only
unsupported Soroban contract tokens) renders as a gray-03 rounded card
with centered medium text instead of bare muted text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
You can't swap a token for itself. Now that "Your tokens" no longer hides
the asset selected on the other side, picking a token that matches the
other picker clears that picker: a destination pick that equals the
source resets the source (and amount), and a source pick that equals the
destination resets the destination (and its token details).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fiat label: the amount cards always render the fiat line — it shows "--"
when the selected asset has no price and "$0.00" for the empty
"(+) Select" state, instead of disappearing. Only the input-type toggle
stays gated on a usable USD price. Applies to both Swap and Send.

Fee label: the swap fee is always shown in XLM regardless of input mode;
it previously flipped to a "$0.00" fiat value in fiat mode. Removes the
now-unused recommendedFeeUsd / xlmPrice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The info sheet styles referenced --font-weight-medium / --font-weight-regular,
which aren't defined anywhere, so those font-weight rules silently no-opped.
Switch to the real SDS tokens (--sds-fw-medium = 500, --sds-fw-regular = 400)
so the title and the inline-emphasized reserve amount render at medium weight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trustline sheet was rendered as a MultiPaneSlider pane inside the
review View, which double-padded its content (View inset + the sheet's
own padding) and rendered the "Got it" CTA in the wrong context. Render
it via InfoBottomSheet (SlideupModal) as an overlay — the same path the
verified/unverified swap sheets use — so it gets single padding and the
correct filled "Got it" button, and matches the Figma bottom sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "You receive" card (and the review step's destination fiat, which is
derived from the same destination rate) showed "--" for non-held tokens
because their price was never fetched. Mirror freighter-mobile:

- Fetch /token-prices for the selected destination canonical, not just the
  account's held balances. This runs as a SEPARATE, best-effort request in
  useGetTokenPrices (additionalAssetIds) and merges its result — so a
  failure pricing the destination can never wipe the reliable balance
  prices (the regression where both cards lost their price).
- Capture stellar.expert's spot `price` from the search result
  (SwapTokenRecord.spotPrice -> DestinationTokenDetails) and use it as a
  mainnet-only fallback when /token-prices has no entry.

The review step needs no change: dstAssetPrice already flows into the
simulation's destinationRate -> dstAmountPriceUsd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The assetScanResults slice slot (reducer + selector + dispatch) was
written on every swap-token lookup but never read back — the live scan
verdicts come from mergeScanResults decorating the lookup payload in
session, not from this cache. Persisting per-token Blockaid verdicts in
extension storage is also a tampering risk we explicitly deferred, so the
slot has no path to becoming useful as-is.

Drops saveAssetScanResults/assetScanResultsSelector and the
assetScanResults state, keeping the in-session mergeScanResults decoration
and the still-used popularTokens slot untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both stellar.expert fetchers behind the swap picker swallowed backend
outages: searchAsset returned res.json() with no res.ok check, and
fetchTrendingAssets caught every error and returned []. A 5xx with a JSON
error body (or any non-records payload) therefore rendered an empty picker
with no indication discovery was down.

Both now throw on a non-ok status (and fetchTrendingAssets lets rejections
propagate), so the swap lookup's catch flips to the held-only fallback and
shows the existing "Token discovery is temporarily unavailable" notice —
matching mobile, which returns null on outage to drive the same state.
A successful-but-empty response still yields []. Aborts remain handled by
the lookup's signal.aborted guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Aligns the extension swap review with mobile, which surfaces the
conversion Rate (1 SRC ≈ N DST) and keeps the minimum-received figure off
the main review. The Rate row was previously suppressed because it was
gated on destMin, which the swap simulation never populates for display.

- Rate row now renders whenever a destination asset is present, computed
  from the send/destination amounts (destMin is still computed for the
  on-chain path-payment floor, just not displayed).
- Removes the Minimum received row and the now-unused destMin prop.
- Hides the Memo row on swaps — swaps are path payments to self and carry
  no memo. Send is unaffected (no destination asset => not a swap).

Fee and XDR rows are kept as-is.

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

When the source is XLM and the destination needs a new trustline, 0.5 XLM
of the spendable balance is locked for the trustline subentry and isn't
actually swappable. The amount screen showed the full spendable, so Max /
percentage buttons and the insufficient-balance check could let the user
stage a swap that leaves no room for the reserve.

Adds deductNewTrustlineReserve, which subtracts BASE_RESERVE from the
displayed source spendable in exactly that case — but only when spendable
is already >= 0.5, so accounts that can't even cover the reserve fall
through to shouldShowXlmReservePreflight and the XlmReserveSheet instead of
seeing a clamped/zero balance. Mirrors mobile's SwapAmountScreen deduction.
The preflight still gates on the undeducted spendable, as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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