From 0a133ae6c90d6ba7147c9cb81615619d1dc04c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 23 Jun 2026 22:25:09 -0300 Subject: [PATCH 001/121] [Extension] Swap to New Token design doc --- extension/specs/swap-to-new-token-design.md | 708 ++++++++++++++++++++ 1 file changed, 708 insertions(+) create mode 100644 extension/specs/swap-to-new-token-design.md diff --git a/extension/specs/swap-to-new-token-design.md b/extension/specs/swap-to-new-token-design.md new file mode 100644 index 0000000000..cc340dbf8e --- /dev/null +++ b/extension/specs/swap-to-new-token-design.md @@ -0,0 +1,708 @@ +# Swap to New Token (Browser Extension) — Design Doc + +> **Status:** Draft for team review · **Author:** Cássio Goulart · **Date:** 2026-06-23 +> +> **Reference (mobile):** This feature already shipped on freighter-mobile — +> PR [stellar/freighter-mobile#879](https://github.com/stellar/freighter-mobile/pull/879) +> and its design doc [`docs/swap-to-new-token-design.md`](https://github.com/stellar/freighter-mobile/blob/main/docs/swap-to-new-token-design.md). +> This document ports that work to the extension; +> +> **Figma ([Freighter Extension file](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8629-18284&t=23iJx0ZSxxk26eJM-1)):** links are inline in §1.2 and §3. + +This document has three main sections to facilitate the review: + +- **§1 High-level design** — summary + architecture diagram. +- **§2 Differences from mobile** — the deltas and nuances vs the mobile design. +- **§3 Technical design** — implementation-grade detail. + +--- + +## §1 — High-level design + +### 1.1 Context & goal + +Today the extension's Swap flow can only swap **between tokens the user already +holds** (assets with an existing trustline). To swap into a new asset, a user +must first leave Swap, complete the "Add asset" flow to create a trustline, and +only then return to Swap. + +**Goal:** let users swap from a held token to **any Stellar classic asset** in a +single flow — discovering the destination through their own balances, a curated +**Popular tokens** list, and free-text search — and **bundling the `changeTrust` +operation into the swap transaction** when the destination has no trustline yet. + +**Out of scope:** swapping to/from **Soroban custom tokens**. The flow stays +classic-only for now (Soroban contract tokens are filtered out at every stage); +Soroban support can come later behind the same discovery/routing seams. + +### 1.2 What changes for users + +| Area | Today | After this work | +| -------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Destination picker** | Held balances only | A "Swap to" picker with **Your tokens**, **Popular tokens**, and (when searching) **Verified** / **Unverified** sections, plus address paste ([Figma — picker default](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35309), [search results](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35483)) | +| **Source picker** | Held balances only | Unchanged in content — same "Swap from" picker, **Your tokens** only ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33048)) | +| **Trustline** | Manual, separate "Add asset" trip | **Automatic** — bundled into the swap as one atomic transaction | +| **Security** | Only held assets are Blockaid-scanned | **Every** destination candidate (held, popular, search result) is Blockaid-scanned before it is selectable, and the combined transaction XDR is scanned at review | +| **New-trustline cost** | Not surfaced | A purple **"This will add a trustline to {CODE}"** banner on review + a tappable info sheet explaining the 0.5 XLM reserve ([Figma — review](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34246), [info sheet](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34721)) | +| **Insufficient XLM for reserve** | On-chain failure | A pre-flight **"You need XLM to create a trustline"** sheet with a _Swap for 0.5 XLM_ helper + _Copy my wallet address_ ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33468)) | + +The Swap home screen has this shape ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8629-32073)): +_You sell_ / _You receive_ cards, a direction chevron, `25% / 50% / 75% / Max` +buttons, and the `Fee · Slippage · Settings` row at the bottom, directly above +the **Review swap** button. Unlike mobile, **there is no "Trending/Popular +tokens" list on the Swap home** — that limited vertical space is reserved for +the percentage buttons and the Fee/Slippage/Settings controls. The Popular list +appears **only inside the "Swap to" picker**. We can later migrate the +"Trending/Popular tokens" list onto the Swap home once we adopt the same +unified transaction settings sheet we have on mobile. + +### 1.3 Architecture (navigation) diagram + +The extension Swap flow is a single `/swap` route whose sub-steps are an internal +`STEPS` state machine (not pushed routes). Purple = new/extended for this work; +slate = exists today. + +```mermaid +flowchart TD + Home([Home / Asset Detail]) -->|tap 'Swap'| Amount[Swap home step — 'You sell' and 'You receive' cards, 'Review swap' button] + + Amount -->|tap 'You sell' token| PickerFrom[Swap from step — 'Your tokens' only] + Amount -->|tap 'You receive' token| PickerTo[Swap to step — idle: 'Your tokens' + 'Popular' / search: 'Your tokens' + 'Verified' + 'Unverified'] + PickerFrom -->|pick held token| Amount + PickerTo -->|pick held or NEW token| Amount + + Amount -->|tap 'Review swap'| ReserveCheck{destination isNew AND available XLM < 0.5 reserve?} + ReserveCheck -->|Yes| XlmSheet[XLM-reserve sheet — 'Swap for 0.5 XLM', 'Copy my wallet address', 'Why do I need XLM?'] + ReserveCheck -->|No| Build[/Build + Blockaid-tx-scan/] + + Build --> Review[Review Tx — 'You are swapping', trustline banner, Blockaid warnings, Wallet, Rate, details] + Review -->|tap trustline banner| TrustInfo[Trustline info sheet] + Review -->|tap 'Confirm'| TxBuild{destination isNew?} + + TxBuild -->|Yes| Atomic[changeTrust op + pathPaymentStrictSend op] + TxBuild -->|No| PathOnly[pathPaymentStrictSend op] + Atomic --> Submit([sign + submit single atomic tx]) + PathOnly --> Submit + Submit --> Done[SubmitTransaction — swapping… / success] + + classDef new fill:#5b3aa8,stroke:#a48cd9,color:#fff + classDef existing fill:#1f2937,stroke:#6b7280,color:#fff + classDef decision fill:#3b3120,stroke:#d97706,color:#fff + class PickerTo,XlmSheet,TrustInfo,Atomic new + class Home,Amount,PickerFrom,Build,Review,PathOnly,Submit,Done existing + class ReserveCheck,TxBuild decision +``` + +**One picker, parameterised.** A single picker component (`SwapAsset`, extended) +serves both sides; a `selectionType: "source" | "destination"` param toggles the +header ("Swap from" / "Swap to"), whether the Popular/search sections appear +(destination only), and whether non-held results are reachable. + +### 1.4 Scope & non-goals + +**In scope** + +- Swap from a held token to any held **or non-held classic** asset, in one flow. +- Destination discovery: held balances + Popular tokens + free-text search. +- Atomic `changeTrust + pathPaymentStrictSend` when the destination is new. +- Blockaid scanning of every destination candidate and of the combined XDR. +- Trustline-reserve education + a pre-flight XLM-reserve helper. + +**Non-goals** + +- Soroban-token swaps (classic-only; Soroban contracts filtered out everywhere). +- A "Trending/Popular tokens" list on the Swap **home** screen (picker only). +- Changing the Send flow's behavior (only shared components are extracted). + +### 1.5 Rollout summary + +- Ship as a single feature branch. The new picker fully replaces the current + held-only swap picker. +- **No feature flag** — the new picker fully replaces the held-only swap picker + directly (same call as mobile). +- Backend stellar.expert proxy is a **separate, non-blocking** follow-up (§3.13); + the frontend ships against stellar.expert directly first. + +--- + +## §2 — Differences between extension and mobile + +The _feature_ is the same; the _platform_ is materially different. + +### 2.1 State management & navigation + +- **Mobile:** Zustand stores (`useSwapStore`, `useTransactionBuilderStore`) + + react-navigation; the picker and amount screen are distinct pushed screens + (`SwapToScreen`, `SwapAmountScreen`). +- **Extension:** **Redux** (`transactionSubmission` slice) + react-router + `HashRouter`; the whole swap is **one `/swap` route** whose steps + (`SwapAmount`, `SwapAsset`, settings, confirm) are an internal **`STEPS` enum + state machine** in [`views/Swap/index.tsx`](../src/popup/views/Swap/index.tsx). + There is no navigation stack — "screens" are conditional renders. + +### 2.2 Send & Swap "live together" + +- **Mobile:** Send and Swap are fully decoupled (separate screens, separate + state machines), and they _share_ reusable `AmountCard` + `PercentageButtons`. +- **Extension:** Send and Swap already **share** the `transactionSubmission` + Redux slice, the `ReviewTx` review modal, the `SubmitTransaction` screen, + `getAvailableBalance`, `useNetworkFees`, and the formatter helpers — but they + have **separate** view files, `STEPS` enums, and amount components. + [`SendAmount`](../src/popup/components/send/SendAmount/index.tsx) owns the + amount card and the `25/50/75/Max` buttons (`PERCENTAGE_OPTIONS`, + `handlePercentage`); [`SwapAmount`](../src/popup/components/swap/SwapAmount/index.tsx) + reimplements its own input and only has a single **Max** button. + - **Decision note:** we will **extract shared `AmountCard` + `PercentageButtons` + components** from `SendAmount` and use them in both flows (matching mobile's + shared-component approach). This is the one place we deliberately refactor + working Send code; see §3.3 for the safety boundary. + +### 2.3 No trending list on the Swap home + +- **Mobile:** the `SwapAmountScreen` renders a virtualized **Trending Tokens** + list as its body, with a `TrendingTokenDetailBottomSheet` ("Buy {code}"). +- **Extension:** **no trending list on the home screen**, and therefore **no + `TrendingTokenDetail` sheet.** The space below the amount cards is occupied by + the `Fee · Slippage · Settings` row. The **Popular tokens** list lives **only + in the "Swap to" picker** (same curated source as mobile — see §3.1). + +### 2.4 Pickers, sheets & the design system + +- **Mobile:** full-screen `SectionList` picker; bottom sheets (`TrustlineInfo`, + `XlmReserve`) via the native bottom-sheet primitive. +- **Extension:** the picker is a **step** inside the fixed **360×600 popup**, + built on the `View` layout primitives + `TokenList`. The mobile bottom sheets + map onto the extension's existing **`SlideupModal`** component — which today + wraps the swap **Review** sheet ([`SwapAmount/index.tsx:641`](../src/popup/components/swap/SwapAmount/index.tsx#L641)) — + or the Radix **`Sheet`** primitive. SDS (`@stellar/design-system`) provides + `Button`, `Input`, `Notification`, `Icon`, `Card`, etc. The purple trustline + banner is an SDS **`Notification`** (the component `ReviewTx` already uses for + warnings); if the installed SDS version has no lilac/"highlight" variant, add a + custom-styled variant — mobile added a `highlight` variant to its own SDS for + exactly this. + +### 2.5 Amount input + +- **Mobile:** migrated to the **system numeric keyboard** + the shared + `useTokenFiatConverter` reducer. +- **Extension:** uses a **DOM ``** with the existing + `formatAmountPreserveCursor` / `cleanAmount` helpers and the existing + `inputType: "crypto" | "fiat"` toggle (lifted from the Swap parent). There is + **no `useTokenFiatConverter`** to adopt; we keep the extension's current + crypto/fiat conversion logic and move it into the shared `AmountCard`. We may + want to revisit this during implementation in case we see that using a similar + useTokenFiatConverter hook would work better for extension too. + +### 2.6 Transaction building, fee & quote + +- **Mobile:** `buildSwapTransaction({ includeTrustline })` prepends `changeTrust` + so a new-token swap is a **single atomic 2-op transaction**; fee is the **total + across ops** (`baseFee = total / opCount`); the quote (best path + + slippage-adjusted `destMin`) is **frozen once the amount is entered** and reused + unchanged through review and submit; Horizon **`op_under_dest_min`** _and_ + **`op_too_few_offers`** rejections trigger an **alert + auto-refetch** of a + fresh quote. +- **Extension today:** [`useSimulateSwapData.getBuiltTx`](../src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx) + builds a **single** `pathPaymentStrictSend`; `changeTrust` is a **standalone** + tx via [`getManageAssetXDR`](../src/popup/helpers/getManageAssetXDR.ts); fee is + applied as the **per-op base fee** (so a 2-op tx would cost ≈2× the displayed + fee); there is **no quote freeze**. + - **Decision — atomic tx:** when the destination is new, build + `changeTrust + pathPaymentStrictSend` as **one atomic 2-op transaction** + (**not** a standalone `changeTrust` tx), exactly like mobile (§3.5). + - **Decision — fee:** adopt the mobile **total-across-ops** fee model for + swaps (divide the user-set total by op count). Send stays 1-op, unchanged. + - **Decision — quote:** **port mobile's quote handling** — freeze the quote + once the amount is entered, reuse it unchanged through review and submit, and + on **`op_under_dest_min` / `op_too_few_offers`** show an alert + **auto-refetch** + a fresh quote (§3.5). + +### 2.7 Slippage default + +- **Mobile:** 2%. **Extension today:** **1%** (`allowedSlippage: "1"` in + [`transactionSubmission.ts:507`](../src/popup/ducks/transactionSubmission.ts#L507) + and `defaultSlippage = "1"` in + [`SwapAmount/index.tsx:61`](../src/popup/components/swap/SwapAmount/index.tsx#L61)). + - **Decision:** change the default to **2%** to match mobile. With the frozen + quote, the wider tolerance materially reduces `op_under_dest_min` / + `op_too_few_offers` rejections between amount entry and submit, improving + success rate. + +### 2.8 Blockaid & caching + +- **Scan timing:** mobile bulk-scans every destination before it is + selectable. The extension scans only held assets + the review XDR today. + **Decision:** adopt mobile's **pick-time bulk scan** of Popular + search + results (closes the same security gap), keeping the review-time XDR scan. +- **Caching:** mobile uses a 3-layer cache (a **module-memory cache that survives + remounts** + a disk-backed 30-min `cachedFetch` + SWR background revalidate), + with the trending list **fetched on swap-screen mount** (not pre-fetched ahead of + time). The extension has its own idioms: **`cachedFetch`** (persistent + `localStorage`, 7-day TTL, background worker) and the **Redux `cache` slice** + (in-memory, with `updatedAt` staleness stamps — its native SWR). **Decision:** + reuse the verified-list cache as-is; cache **Popular tokens** + **Blockaid scan + results** in the Redux `cache` slice with `updatedAt` staleness (~30-min window); + back the Popular list with a short-TTL (~30-min) `cachedFetch`-style persistent + entry so frequent popup reopens paint instantly. **No** separate module-memory + cache (the popup refetches on open; low value given its lifecycle). + +### 2.9 Naming map (mobile → extension) + +| Mobile | Extension equivalent | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `SwapToScreen` (picker) | `SwapAsset` step, extended + parameterised | +| `SwapAmountScreen` | `SwapAmount` step | +| `useSwapTokenLookup` | new `useSwapTokenLookup` (extension) — a parallel impl **built from** `searchAsset` + verified lists + Popular fetch + bulk scan | +| shared `AmountCard` / `PercentageButtons` | new shared `AmountCard` / `PercentageButtons` extracted from `SendAmount` (mirrors mobile's shared components) | +| `useTokenFiatConverter` | existing `inputType` toggle + `formatAmountPreserveCursor` (no new hook) | +| `buildSwapTransaction({ includeTrustline })` | extended `useSimulateSwapData.getBuiltTx` + extracted `buildChangeTrustOperation` | +| `DestinationTokenDescriptor` | `destinationAsset` canonical string **+** new `destinationTokenDetails` object on `TransactionData` (§3.4) | +| `TrustlineInfoBottomSheet` / `XlmReserveBottomSheet` | `SlideupModal`/`Sheet`-based info sheets | +| `useSwapStore` / `useTransactionBuilderStore` (Zustand) | `transactionSubmission` Redux slice | +| `SWAP_*` Amplitude events | `METRIC_NAMES.*` + `emitMetric` | + +--- + +## §3 — Technical design + +Implementation-grade. File paths are repo-relative to `extension/`. Existing +symbols are linked; **NEW** marks net-new code. + +### 3.1 Destination-token discovery — `useSwapTokenLookup` (NEW) + +A new hook owns destination discovery, mirroring mobile's `useSwapTokenLookup` +but built from the extension's existing search/verification primitives. It lives +at `src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts` and is a +parallel implementation (not a wrapper) of the held-only +[`useSwapFromData`](../src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx), +which stays for the **source** side / `holdsOnly` case. + +It exposes two surfaces, switched on whether the search term is empty. + +**Idle (no search term) — destination side:** two ordered sections. + +1. **Your tokens** — from the user's balances (classic only; XLM included), + reusing the held-balance fetch in `useSwapFromData` / `useGetSwapAmountData`. +2. **Popular tokens** — intersection of: + + - **stellar.expert top assets by `volume7d`** — a single un-paginated call for + the **top 50** (`limit=50`, matching stellar.expert's default page size, so no + over-fetch; new fetch — see §3.13), and + - the runtime **verified-token lists** already cached via the asset-lists + pipeline ([`getVerifiedTokens`](../src/popup/helpers/searchAsset.ts) / + `splitVerifiedAssetCurrency`, [`ducks/cache.ts`](../src/popup/ducks/cache.ts) + `tokenLists`). + + Held tokens are filtered **out** of the Popular section (so a user never sees + a held token twice on the same screen). Mainnet applies a minimum-volume floor + inside the cache layer before caching (mirroring mobile's `MIN_TRENDING_VOLUME7D`); + on **testnet** `volume7d` is always 0, so the `sort=volume7d&order=desc` query + params are **omitted** (accept the API's default order), the floor is a no-op, + and the verified-list intersection is what produces a meaningful list. + + **New account / no held balances:** the **Your tokens** section is omitted and + the idle picker renders **only Popular tokens** (matching mobile). + +**Active (with search term) — destination side:** three labeled, mutually +exclusive sections (matching [Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35483)): + +1. **Your tokens** — held tokens matching code / domain (partial match). +2. **Verified** — verified-list matches (excluding §1); section header carries a + tappable **(i)** info icon → `VerifiedTokenInfoSheet` (NEW, §3.7). +3. **Unverified** — remaining stellar.expert + [`searchAsset`](../src/popup/helpers/searchAsset.ts) results (excluding the + above); header carries its own **(i)** → `UnverifiedTokenInfoSheet` (NEW). + +**Classic-only filter.** Every record (idle or search) passes through +[`isContractId`](../src/popup/helpers/soroban.ts) / `isAssetSac` so Soroban +contract tokens are dropped. A `C…` paste that resolves to a wrapped classic +(SAC) surfaces its classic asset; a pure-Soroban paste yields nothing. When the +filtered result set is empty **and** the term is a contract address (or the +pre-filter set contained Soroban matches), show the empty-state copy _"Soroban +contract tokens aren't supported for swaps yet. Try searching for a Classic token +instead."_ (track a `hadSorobanMatches` flag, as mobile does). For a normal +no-match search (term isn't a contract address, no Soroban matches), show the +generic _"No tokens match {term}"_ empty state, reusing the Add-asset / +`ManageAssetRows` empty-state pattern. + +**Blockaid bulk scan.** Every candidate **not** already in the user's +balances is scanned via +[`scanAssetBulk`](../src/popup/helpers/blockaid.ts) in `MAX_ASSETS_TO_SCAN` +(=10) chunks; results merge onto each record using the existing +`isAssetMalicious` / `isAssetSuspicious` / `shouldTreatAssetAsUnableToScan` +helpers. Mainnet-only (`isBlockaidEnabled`); on testnet the state is +"unable to scan", as today. Held tokens already carry their balance scan. + +**Search mechanics.** Reuse `useSwapFromData`'s existing **300 ms lodash +debounce** + `AbortController` cancellation so the trailing keystroke wins; +dedupe by canonical `CODE:ISSUER`. `searchAsset` already targets +`${getApiStellarExpertUrl(networkDetails)}/asset?search=` per network. (The +extension keeps its existing **300 ms** debounce rather than mobile's 500 ms — +reusing the held-search primitive; the difference is immaterial.) + +**Caching.** See §2.8 — verified lists reuse the existing cache; Popular + +scan results go in the Redux `cache` slice with `updatedAt` staleness; Popular +gets a short-TTL persistent `cachedFetch`-style entry. + +**Graceful fallback (stellar.expert unreachable).** Held-to-held swaps must keep +working. When the Popular/search fetch fails (and no fresh cache exists): + +- the picker shows **only "Your tokens"** (Popular section omitted), +- search degrades to **held-only** in-memory matches, +- a **soft inline notice** renders at the top of the picker ("Token discovery is + temporarily unavailable. You can still swap between tokens you already hold."), + non-blocking. + +Path-finding is unaffected — it uses Horizon `strictSendPaths` +([`horizonGetBestPath`](../src/popup/helpers/horizonGetBestPath.ts)), not +stellar.expert. + +### 3.2 Picker UI — extend `SwapAsset` (parameterised) + +Extend [`SwapAsset`](../src/popup/components/swap/SwapAsset/index.tsx) (currently +`{ title, hiddenAssets, onClickAsset, goBack }`) with a +`selectionType: "source" | "destination"` prop: + +- **source** → `holdsOnly` path (current `useSwapFromData`); single **Your tokens** + section; header "Swap from". +- **destination** → `useSwapTokenLookup` (§3.1); sectioned list; header "Swap to"; + search bar with **Paste** button. + +Rendering reuses [`TokenList`](../src/popup/components/InternalTransaction/TokenList/index.tsx) +and the verified/unverified section layout from +[`ManageAssetRows`](../src/popup/components/manageAssets/ManageAssetRows/index.tsx). +A **`SwapTokenRow`** (NEW, or an extension of the existing row) renders, by section: + +- **held** — fiat balance + 24h % (as on the Home balance row). +- **non-held** (Verified / Unverified) — a `⋯` context menu (**Copy address**, + **View on stellar.expert** via the existing in-app-browser/`getStellarExpertUrl` + pattern) and a Blockaid badge ([`ScamAssetIcon`](../src/popup/components/account/ScamAssetIcon/index.tsx)) + overlaid on the icon when suspicious/malicious. + +The picker prevents nothing by default — malicious destinations remain selectable +but surface their warning in the row and again at review (matching the "Confirm +anyway" pattern). Native XLM is always trusted. + +### 3.3 Amount screen — extract shared `AmountCard` + `PercentageButtons` + +Extract two components from +[`SendAmount`](../src/popup/components/send/SendAmount/index.tsx): + +- **`AmountCard`** (NEW, shared location e.g. + `src/popup/components/amount/AmountCard`) — the rounded card with label, + available-balance line, the crypto/fiat dual input (`inputType` toggle, + `formatAmountPreserveCursor`, dynamic span-measured input width), the asset + selector button, and the secondary fiat line. Driven by props, not by Send/Swap + internals. It also accepts an optional `securityLevel` and overlays the + **`ScamAssetIcon`** badge on the selected token icon when the token is + malicious/suspicious — so the Blockaid warning stays visible **in place** on the + Swap home after the picker is dismissed (the extension analogue of mobile's + `TokenIconWithBadge`; mobile §9 / Figma 8629-19445). The Sell side reads the + source balance's scan; the Receive side reads + `destinationTokenDetails.securityLevel` (§3.4). +- **`PercentageButtons`** (NEW) — the `25% / 50% / 75% / Max` group + (`PERCENTAGE_OPTIONS` + `handlePercentage`), parameterised by an + `availableBalance` and an `onSelect(pct)` callback. + +`SwapAmount` then renders two `AmountCard`s (You sell = editable; You receive = +read-only, fed by the path-finder result) + `PercentageButtons` + the direction +chevron + the `Fee · Slippage · Settings` row, replacing its bespoke input and +single Max button. + +**Safety boundary.** The extraction must be behavior-preserving for Send: + +1. Land the extracted components and **migrate `SendAmount` to them first**, with + the existing Send E2E + unit tests green (pure refactor, no UX change). +2. Only then wire `SwapAmount` to them. + +`InputWidthContext` ([`views/Send/contexts/inputWidthContext.tsx`](../src/popup/views/Send/contexts/inputWidthContext.tsx)) +is Send-local today; either lift it to a shared provider used by both flows or +let `AmountCard` own its width state internally (preferred — keeps the component +self-contained). + +**Spendable amount.** Reuse [`getAvailableBalance`](../src/popup/helpers/soroban.ts) +(deducts XLM minimum reserve + fee). For a **new-token** swap the destination +trustline adds **0.5 XLM** to the required reserve on the source side when the +source is XLM; the spendable/`Max` computation and the CTA gating must account +for it (see §3.6 for the pre-flight check). + +**CTA states & the post-scan unable-to-scan gate.** The single **Review swap** +button mirrors mobile's CTA state machine (mobile §6.6). Most states already exist +in today's `SwapAmount` and are unchanged: **select** (a side unset → "Select an +asset", taps to the picker), **enter** (both set, amount 0 → "Enter an amount"), +**insufficient** (amount > spendable → disabled), **loading** (path-finding in +flight), **review** (valid + path found → "Review swap"). The **net-new** behavior +is the **post-scan unable-to-scan gate**: because we now scan the destination +token (§3.1) and the combined XDR (§3.9), the **Review swap** tap must **build + +scan first, then decide from the fresh scan result** — if any side (source/ +destination token scan **or** the transaction-level XDR scan) is unable-to-scan, +surface an acknowledgement (the existing Blockaid warning surface) **before** +opening the review, then proceed. On the `Review` branch this sits between the +reserve pre-flight (§3.6) and the review sheet (§3.7). + +### 3.4 Destination representation — descriptor without breaking the canonical string + +The extension stores the destination as a **canonical string** +(`destinationAsset` on `TransactionData`), and downstream code +(`getAssetFromCanonical`, `isPathPaymentSelector` = `destinationAsset !== ""`, +path-finding, `getBuiltTx`) depends on that shape. Rather than replace it, **keep +`destinationAsset` as the canonical-string key** and add a sibling object that +carries the non-held metadata: + +```ts +// transactionSubmission TransactionData — NEW field +destinationTokenDetails: { + tokenCode: string; // e.g. "AQUA" / "XLM" — lets the banner, review rows, + // and warnings render without re-parsing destinationAsset + isNew: boolean; // true when the user has no trustline for it + decimals: number; // 7 for classic (mobile may also read tomlInfo) + issuer?: string; // omitted for native XLM + securityLevel?: SecurityLevel; // from the bulk scan + iconUrl?: string; // from the search record, before balances hydrate +} | null; +``` + +`destinationAsset` (the canonical string) stays the identity/key used by +path-finding, build, selectors, and Send; `destinationTokenDetails` carries the +display + non-held metadata. `tokenCode` + `issuer` together fully describe the +asset for rendering, so consumers never have to re-split the canonical string. + +Populated by `saveDestinationAsset` (or a new `saveDestinationTokenDetails` +reducer) when a row is picked: held rows → `isNew: false` (from the balance), +non-held rows → `isNew: true` (from the `searchAsset` / Popular record). This is +the extension's analogue of mobile's `DestinationTokenDescriptor`, minimally +invasive to the existing plumbing. + +Two fields from mobile's descriptor are intentionally **dropped**: `tokenType` +(mobile keeps it for its Soroban gate; the §3.1 classic-only filter guarantees +every destination is a classic asset, and the canonical string + `issuer` already +imply native-vs-classic, so no type discriminator is needed here), and +`securityWarnings[]` (we keep only `securityLevel` on the slot and re-feed the live +bulk-scan / XDR-scan results into the Blockaid components at review — §3.7 — rather +than snapshotting a warnings array on the descriptor). + +### 3.5 Atomic transaction — bundle `changeTrust` + `pathPaymentStrictSend` + +**Extract the op builder.** Pull the op-creation out of +[`getManageAssetXDR`](../src/popup/helpers/getManageAssetXDR.ts) into a shared +helper so both Add-asset and Swap use it: + +```ts +// NEW — src/popup/helpers/getManageAssetXDR.ts (or a sibling) +buildChangeTrustOperation({ assetCode, assetIssuer, isRemove = false, sdk }): + xdr.Operation // Operation.changeTrust({ asset: new Asset(code, issuer), ...(isRemove ? {limit:'0'} : {}) }) +``` + +`getManageAssetXDR` is refactored to call it internally (no behavior change for +Add-asset). + +**Extend the swap builder.** In +[`useSimulateSwapData.getBuiltTx`](../src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx), +when `destinationTokenDetails.isNew === true`, **prepend** the `changeTrust` op +(op index 0) before `pathPaymentStrictSend` (op index 1), so both submit +atomically in one transaction: + +``` +op[0] = buildChangeTrustOperation({ assetCode, assetIssuer }) // only when isNew +op[1] = Operation.pathPaymentStrictSend({ sendAsset, sendAmount, destination: self, destAsset, destMin, path }) +``` + +A guard throws if `isNew` but `issuer` is missing (unreachable — XLM can't be new +and Soroban is filtered — but fail-fast before an on-chain `tx_no_trust`). + +**Fee = total across ops.** Today the builder sets the `TransactionBuilder` +`fee` to `xlmToStroop(fee)` (the per-op base fee). Change it so the user-set fee +is the **total**: per-op base fee = `xlmToStroop(totalFee) / opCount`, clamped to +the 100-stroop network minimum, where `opCount = isNew ? 2 : 1`. A 2-op swap then +charges exactly the displayed total. Send is always 1-op (unchanged). The fee +input's recommended default / minimum should scale with `opCount` so the +displayed value doesn't jump. + +**Quote freeze + expiry recovery (ported).** Freeze the path-finder's best +`destinationAmount` and the slippage-adjusted `destMin` +([`computeDestMinWithSlippage`](../src/helpers/transaction.ts), now defaulting to +**2%**) **once the amount is entered** (when path-finding resolves) — _before_ +the review step — and reuse them **unchanged through review and submit** (never +re-quoted at submit). If Horizon rejects with a quote-expired op code — +**`op_under_dest_min`** _or_ **`op_too_few_offers`** — classify it specially (a +`getQuoteExpiredOperationCodes`-style helper over `resultCodes.operations`; this +concrete code set `["op_under_dest_min", "op_too_few_offers"]` matches mobile's +`quoteErrors.ts`), show an **alert** (the extension's toast/`Notification`) reading +_"Quote has expired, please try again to get a new quote"_, fire the dedicated +metric (§3.10) instead of a generic swap-fail, and **auto-refetch** a fresh path +(`getBestPath`) so the retry uses a new quote. + +**Sign & submit unchanged.** The combined 2-op XDR flows through the existing +[`signFreighterTransaction`](../src/popup/ducks/transactionSubmission.ts) → +[`submitFreighterTransaction`](../src/popup/ducks/transactionSubmission.ts) +pipeline (verify the internal signing API accepts arbitrary op counts — expected +yes). + +### 3.6 Pre-flight XLM-reserve check + XLM-reserve sheet (NEW) + +Before opening the review for a **new-token** swap, run a pure predicate +(`shouldShowXlmReservePreflight`, new helper) to decide whether to surface the +**XLM-reserve sheet** instead ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33468)): + +- Returns `false` when the destination isn't new (no trustline op → no reserve + concern). +- **XLM source:** gate on spendable XLM `< BASE_RESERVE` (0.5 XLM). The amount + screen already deducts the reserve up-front, so this only catches accounts that + can't cover 0.5 to begin with. +- **Non-XLM source:** gate on post-fee XLM headroom `<= BASE_RESERVE` for the + extra `changeTrust` op (`getAvailableBalance` already subtracts the full fee, + which is now the true total, so no extra op-fee subtraction here). + +**`XlmReserveSheet`** (NEW, `SlideupModal`/`Sheet`): explains the one-time 0.5 XLM +reserve, plus — + +- **"Swap for 0.5 XLM"** — sets XLM as the receive token, picks a non-XLM classic + source (current source if it qualifies, else the best non-XLM balance), and + pre-fills the sell amount via Horizon `strictReceivePaths` so the user receives + ~0.5 XLM; falls back to no pre-fill on a missing path. Hidden when no qualifying + source exists (e.g. XLM-only account). +- **"Copy my wallet address"** — copies the active `G…` (existing clipboard util). +- **"Why do I need XLM?"** — inline link to the help article via the existing + in-app-browser pattern. + +### 3.7 Review extensions + info sheets + +In [`ReviewTx`](../src/popup/components/InternalTransaction/ReviewTransaction/index.tsx) +(shared with Send; gets `dstAsset` for swaps): + +- **Trustline banner** — when `destinationTokenDetails.isNew`, render a purple SDS + `Notification` _"This will add a trustline to {CODE}"_ with a chevron → opens + **`TrustlineInfoSheet`** (NEW) explaining the 0.5 XLM reserve is one-time and + refundable when the trustline is removed ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34721)). + Add a lilac/`highlight` SDS `Notification` variant if one doesn't exist. +- **Blockaid warnings** — feed the destination's bulk-scan result and the + combined-XDR scan (already produced by `useSimulateSwapData` via + [`useScanTx`](../src/popup/helpers/blockaid.ts)) into the existing + `BlockaidTxScanLabel` / `BlockAidScanExpanded` / `ScamAssetIcon` components. A + malicious/suspicious destination shows the red/amber banner and flips the + footer to **Cancel** + **Confirm anyway** ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8629-19445)); + fold a transaction-level **unable-to-scan** into the caution banner. +- **`VerifiedTokenInfoSheet` / `UnverifiedTokenInfoSheet`** (NEW) — the picker + section **(i)** sheets. +- **Rate / details / minimum-received** — the Figma review shows a `Rate` + (`1 {src} ≈ {n} {dst}`) and a `Transaction details` row. Verify whether the + shared `ReviewTx` already renders these; if not, add a swap-rate row (a + `calculateSwapRate`-style helper) and a **minimum-received** value computed from + the **frozen `destMin`** (§3.5). Mobile's analogue is + `SwapTransactionDetailsBottomSheet` + `calculateSwapRate`. + +### 3.8 Redux state changes (`transactionSubmission`) + +In [`ducks/transactionSubmission.ts`](../src/popup/ducks/transactionSubmission.ts): + +- `allowedSlippage` default `"1"` → **`"2"`** (line 507) and `defaultSlippage` + `"1"` → `"2"` in [`SwapAmount/index.tsx:61`](../src/popup/components/swap/SwapAmount/index.tsx#L61). +- Add `destinationTokenDetails` to `TransactionData` (§3.4) + its reducer. +- Freeze fields for the quote (`destinationAmount`, frozen `destMin`) already + largely exist (`saveSwapBestPath`); add what's needed for expiry detection. +- No change to `saveAsset` / `getBestPath` / sign / submit signatures. + +### 3.9 Blockaid integration summary + +Everything needed already exists in [`helpers/blockaid.ts`](../src/popup/helpers/blockaid.ts) +and [`components/WarningMessages`](../src/popup/components/WarningMessages/index.tsx): + +- **Pick-time:** `scanAssetBulk` in `useSwapTokenLookup` (mainnet-only). +- **Review-time:** `useScanTx` on the combined `changeTrust + pathPaymentStrictSend` + XDR (already wired in `useSimulateSwapData`; it now scans 2 ops). +- **Caching:** add a session/Redux scan-result cache (new — the extension has none + today) keyed by asset id with `updatedAt`, so the picker doesn't re-scan within + a session. +- **In-place badges:** `ScamAssetIcon` renders the warning on (a) picker rows + (§3.2), (b) the selected Sell/Receive token icon on the Swap home `AmountCard` + (§3.3 — persists after the picker closes), and (c) the review sheet (§3.7) — the + extension analogue of mobile's `TokenIconWithBadge` surfaces. + +### 3.10 Telemetry + +Add new entries to [`constants/metricsNames.ts`](../src/popup/constants/metricsNames.ts) +(which already has `viewSwap`, `swapFrom`, `swapTo`, `swapAmount`, `swapConfirm`, +…) and emit via `emitMetric`, mirroring mobile's `SWAP_*` set: + +- swap **from**/**to** picker opened (`{ source: "cta" | "dropdown" }` — `cta` = + the empty-state "Select an asset" button, `dropdown` = tapping the token chip in + the You sell / You receive card) +- **source** selected (`{ tokenCode, tokenIssuer, source: "balances" | "search" }` + — the source picker is held-only, so no `popular` / `isNew`) +- **destination** selected (`{ tokenCode, tokenIssuer, isNew, source: "balances" | "popular" | "search" }`) +- direction toggled +- trustline added (on confirmed combined tx) +- XLM-reserve-insufficient shown +- quote expired (`{ sourceToken, destToken, sourceAmount, destAmount, allowedSlippage, resultCode }`) + — fired instead of swap-fail (§3.5); `allowedSlippage` lets us measure the 2% + default's effect (§2.7) and `resultCode` carries the Horizon op code(s) + +These measure the discovery → swap funnel and first-time trustline creation. + +### 3.11 i18n + +All new copy goes through `i18next` +([`helpers/localizationConfig.ts`](../src/popup/helpers/localizationConfig.ts), +locales `en` + `pt`): section headers, the empty/Soroban states, the soft +fallback notice, the trustline banner + info sheet, the XLM-reserve sheet, the +verified/unverified info sheets, and the quote-expired message. + +### 3.12 Testing + +- **Unit (Jest):** + - `useSwapTokenLookup` ordering/dedupe (held → popular[volume7d ∩ verified] → + search remainder), Soroban filtering, `hadSorobanMatches` empty-state, + held-only fallback. + - `buildChangeTrustOperation` + the extended `getBuiltTx`: `isNew` produces + `changeTrust` as op[0] and `pathPaymentStrictSend` as op[1]; non-new produces + the single op (regression). + - Fee total-across-ops: per-op base fee = total/opCount, clamped; 1-op + send/swap unchanged. + - Quote-expiry classification → expiry path (message + refetch) vs generic fail. + - `shouldShowXlmReservePreflight` branches (XLM vs non-XLM source). + - `AmountCard` / `PercentageButtons` extraction: Send behavior preserved. +- **E2E (Playwright,** [`e2e-tests/`](../e2e-tests)**):** the Playwright spec + **replaces mobile's manual/integration matrix** (mobile §12). There is **no swap + E2E test today** (only `sendPayment` / `sendCollectible`). Add a `swap` spec + covering held-to-held (regression), swap-to-new-token happy path (picker → + non-held pick → review trustline banner → confirm → combined tx), the + XLM-reserve sheet, a Blockaid-flagged destination, search (verified / unverified + / Soroban empty state), the **stellar.expert-unreachable fallback** (Popular + omitted + held-only search + soft notice — §3.1), and **testnet** behavior + (Blockaid badges absent / unable-to-scan), reusing the existing fixtures/stubs. +- **Regression:** Add-asset still calls `getManageAssetXDR` (delegating to + `buildChangeTrustOperation`); Send amount input behavior unchanged after the + `AmountCard` extraction. + +### 3.13 Backend follow-up (non-blocking) + +Mirror mobile: we've filed a `freighter-backend-v2` issue for a +`GET /stellar-expert/asset` proxy ([#102](https://github.com/stellar/freighter-backend-v2/issues/102)) so we get our API key + higher rate limits. +Until it lands, call stellar.expert directly via the existing +[`searchAsset`](../src/popup/helpers/searchAsset.ts) pattern (the new Popular/ +`volume7d` call routes per-network the same way). The frontend migrates by +swapping the base URL. + +### 3.14 Rollout + +Ship as a single feature branch; the new picker fully replaces the held-only swap +picker. **No feature flag** — consistent with mobile, the change is incremental +enough that flagging adds more risk than it removes. No data migration is +required. + +--- + +## Appendix — Reference designs (Figma, Freighter Extension file) + +| Screen | Figma | +| ---------------------------- | -------------------------------------------------------------------------------------------------------- | +| Swap home (no trending list) | [8629-32073](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8629-32073) | +| Swap to (picker, default) | [8641-35309](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35309) | +| Swap to (search results) | [8641-35483](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35483) | +| Swap from (picker, default) | [8641-33048](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33048) | +| Sell side focused | [8645-46251](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8645-46251) | +| Sell side with amount | [8641-32549](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-32549) | +| Review with trustline banner | [8641-34246](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34246) | +| Trustline info sheet | [8641-34721](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34721) | +| Review with Blockaid warning | [8629-19445](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8629-19445) | +| Add XLM bottom sheet | [8641-33468](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33468) | From e351df6c672f5a503dbc962c5e76e194ec89c1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 15:38:12 -0300 Subject: [PATCH 002/121] [Extension] Drop Paste button from Swap to New Token design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- extension/specs/swap-to-new-token-design.md | 23 +++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/extension/specs/swap-to-new-token-design.md b/extension/specs/swap-to-new-token-design.md index cc340dbf8e..046f0c1b5e 100644 --- a/extension/specs/swap-to-new-token-design.md +++ b/extension/specs/swap-to-new-token-design.md @@ -37,14 +37,14 @@ Soroban support can come later behind the same discovery/routing seams. ### 1.2 What changes for users -| Area | Today | After this work | -| -------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Destination picker** | Held balances only | A "Swap to" picker with **Your tokens**, **Popular tokens**, and (when searching) **Verified** / **Unverified** sections, plus address paste ([Figma — picker default](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35309), [search results](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35483)) | -| **Source picker** | Held balances only | Unchanged in content — same "Swap from" picker, **Your tokens** only ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33048)) | -| **Trustline** | Manual, separate "Add asset" trip | **Automatic** — bundled into the swap as one atomic transaction | -| **Security** | Only held assets are Blockaid-scanned | **Every** destination candidate (held, popular, search result) is Blockaid-scanned before it is selectable, and the combined transaction XDR is scanned at review | -| **New-trustline cost** | Not surfaced | A purple **"This will add a trustline to {CODE}"** banner on review + a tappable info sheet explaining the 0.5 XLM reserve ([Figma — review](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34246), [info sheet](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34721)) | -| **Insufficient XLM for reserve** | On-chain failure | A pre-flight **"You need XLM to create a trustline"** sheet with a _Swap for 0.5 XLM_ helper + _Copy my wallet address_ ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33468)) | +| Area | Today | After this work | +| -------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Destination picker** | Held balances only | A "Swap to" picker with **Your tokens**, **Popular tokens**, and (when searching) **Verified** / **Unverified** sections ([Figma — picker default](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35309), [search results](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-35483)) | +| **Source picker** | Held balances only | Unchanged in content — same "Swap from" picker, **Your tokens** only ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33048)) | +| **Trustline** | Manual, separate "Add asset" trip | **Automatic** — bundled into the swap as one atomic transaction | +| **Security** | Only held assets are Blockaid-scanned | **Every** destination candidate (held, popular, search result) is Blockaid-scanned before it is selectable, and the combined transaction XDR is scanned at review | +| **New-trustline cost** | Not surfaced | A purple **"This will add a trustline to {CODE}"** banner on review + a tappable info sheet explaining the 0.5 XLM reserve ([Figma — review](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34246), [info sheet](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34721)) | +| **Insufficient XLM for reserve** | On-chain failure | A pre-flight **"You need XLM to create a trustline"** sheet with a _Swap for 0.5 XLM_ helper + _Copy my wallet address_ ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-33468)) | The Swap home screen has this shape ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8629-32073)): _You sell_ / _You receive_ cards, a direction chevron, `25% / 50% / 75% / Max` @@ -180,6 +180,11 @@ The _feature_ is the same; the _platform_ is materially different. warnings); if the installed SDS version has no lilac/"highlight" variant, add a custom-styled variant — mobile added a `highlight` variant to its own SDS for exactly this. +- **No clipboard "Paste" button.** Mobile offers a one-tap paste affordance on the + search bar; the extension **omits** it — a programmatic clipboard read needs an + extra `clipboardRead` manifest permission + user opt-in, which the team decided + isn't worth it on web. Users can still paste an address into the search field + manually (`⌘/Ctrl+V`). ### 2.5 Amount input @@ -366,7 +371,7 @@ Extend [`SwapAsset`](../src/popup/components/swap/SwapAsset/index.tsx) (currentl - **source** → `holdsOnly` path (current `useSwapFromData`); single **Your tokens** section; header "Swap from". - **destination** → `useSwapTokenLookup` (§3.1); sectioned list; header "Swap to"; - search bar with **Paste** button. + search bar. Rendering reuses [`TokenList`](../src/popup/components/InternalTransaction/TokenList/index.tsx) and the verified/unverified section layout from From bb5be7e4a87392087812ba44d705b90e352f0da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 15:44:33 -0300 Subject: [PATCH 003/121] [Extension] Omit Popular tokens on custom networks in Swap design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- extension/specs/swap-to-new-token-design.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/extension/specs/swap-to-new-token-design.md b/extension/specs/swap-to-new-token-design.md index 046f0c1b5e..23c578a46d 100644 --- a/extension/specs/swap-to-new-token-design.md +++ b/extension/specs/swap-to-new-token-design.md @@ -164,7 +164,11 @@ The _feature_ is the same; the _platform_ is materially different. - **Extension:** **no trending list on the home screen**, and therefore **no `TrendingTokenDetail` sheet.** The space below the amount cards is occupied by the `Fee · Slippage · Settings` row. The **Popular tokens** list lives **only - in the "Swap to" picker** (same curated source as mobile — see §3.1). + in the "Swap to" picker** (same curated source as mobile — see §3.1). On + **custom networks** the Popular section is omitted entirely — an extension-only + concern (mobile has no custom networks): verified-token lists and + stellar.expert cover only Mainnet/Testnet; the picker falls back to held-only + there (§3.1). ### 2.4 Pickers, sheets & the design system @@ -362,6 +366,15 @@ Path-finding is unaffected — it uses Horizon `strictSendPaths` ([`horizonGetBestPath`](../src/popup/helpers/horizonGetBestPath.ts)), not stellar.expert. +**Network support (Mainnet / Testnet only).** Token discovery beyond held +balances depends on resources that exist **only for Mainnet and Testnet** — the +verified-token lists and stellar.expert. On a **custom network** (the extension +supports custom networks; mobile does not) the picker **omits the Popular +section** and search **degrades to held-only** in-memory matches — the same +held-only shape as the stellar.expert-unreachable fallback above, but a permanent +state rather than an error. Held-to-held swaps still work (Horizon +`strictSendPaths` against the custom network's Horizon). + ### 3.2 Picker UI — extend `SwapAsset` (parameterised) Extend [`SwapAsset`](../src/popup/components/swap/SwapAsset/index.tsx) (currently From 26112a5ebf9e786b66ca5f91d3292796576feeb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 15:51:48 -0300 Subject: [PATCH 004/121] [Extension] Rename isNew -> requiresTrustline in Swap design doc 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 --- extension/specs/swap-to-new-token-design.md | 47 +++++++++++---------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/extension/specs/swap-to-new-token-design.md b/extension/specs/swap-to-new-token-design.md index 23c578a46d..6a3741f0f0 100644 --- a/extension/specs/swap-to-new-token-design.md +++ b/extension/specs/swap-to-new-token-design.md @@ -71,13 +71,13 @@ flowchart TD PickerFrom -->|pick held token| Amount PickerTo -->|pick held or NEW token| Amount - Amount -->|tap 'Review swap'| ReserveCheck{destination isNew AND available XLM < 0.5 reserve?} + Amount -->|tap 'Review swap'| ReserveCheck{destination requiresTrustline AND available XLM < 0.5 reserve?} ReserveCheck -->|Yes| XlmSheet[XLM-reserve sheet — 'Swap for 0.5 XLM', 'Copy my wallet address', 'Why do I need XLM?'] ReserveCheck -->|No| Build[/Build + Blockaid-tx-scan/] Build --> Review[Review Tx — 'You are swapping', trustline banner, Blockaid warnings, Wallet, Rate, details] Review -->|tap trustline banner| TrustInfo[Trustline info sheet] - Review -->|tap 'Confirm'| TxBuild{destination isNew?} + Review -->|tap 'Confirm'| TxBuild{destination requiresTrustline?} TxBuild -->|Yes| Atomic[changeTrust op + pathPaymentStrictSend op] TxBuild -->|No| PathOnly[pathPaymentStrictSend op] @@ -472,7 +472,7 @@ carries the non-held metadata: destinationTokenDetails: { tokenCode: string; // e.g. "AQUA" / "XLM" — lets the banner, review rows, // and warnings render without re-parsing destinationAsset - isNew: boolean; // true when the user has no trustline for it + requiresTrustline: boolean; // true when the user has no trustline for it decimals: number; // 7 for classic (mobile may also read tomlInfo) issuer?: string; // omitted for native XLM securityLevel?: SecurityLevel; // from the bulk scan @@ -486,10 +486,10 @@ display + non-held metadata. `tokenCode` + `issuer` together fully describe the asset for rendering, so consumers never have to re-split the canonical string. Populated by `saveDestinationAsset` (or a new `saveDestinationTokenDetails` -reducer) when a row is picked: held rows → `isNew: false` (from the balance), -non-held rows → `isNew: true` (from the `searchAsset` / Popular record). This is -the extension's analogue of mobile's `DestinationTokenDescriptor`, minimally -invasive to the existing plumbing. +reducer) when a row is picked: held rows → `requiresTrustline: false` (from the +balance), non-held rows → `requiresTrustline: true` (from the `searchAsset` / +Popular record). This is the extension's analogue of mobile's +`DestinationTokenDescriptor`, minimally invasive to the existing plumbing. Two fields from mobile's descriptor are intentionally **dropped**: `tokenType` (mobile keeps it for its Soroban gate; the §3.1 classic-only filter guarantees @@ -516,23 +516,25 @@ Add-asset). **Extend the swap builder.** In [`useSimulateSwapData.getBuiltTx`](../src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx), -when `destinationTokenDetails.isNew === true`, **prepend** the `changeTrust` op -(op index 0) before `pathPaymentStrictSend` (op index 1), so both submit -atomically in one transaction: +when `destinationTokenDetails.requiresTrustline === true`, **prepend** the +`changeTrust` op (op index 0) before `pathPaymentStrictSend` (op index 1), so +both submit atomically in one transaction: ``` -op[0] = buildChangeTrustOperation({ assetCode, assetIssuer }) // only when isNew +op[0] = buildChangeTrustOperation({ assetCode, assetIssuer }) // only when requiresTrustline op[1] = Operation.pathPaymentStrictSend({ sendAsset, sendAmount, destination: self, destAsset, destMin, path }) ``` -A guard throws if `isNew` but `issuer` is missing (unreachable — XLM can't be new -and Soroban is filtered — but fail-fast before an on-chain `tx_no_trust`). +A guard throws if `requiresTrustline` but `issuer` is missing (unreachable — +XLM can't be new and Soroban is filtered — but fail-fast before an on-chain +`tx_no_trust`). **Fee = total across ops.** Today the builder sets the `TransactionBuilder` `fee` to `xlmToStroop(fee)` (the per-op base fee). Change it so the user-set fee is the **total**: per-op base fee = `xlmToStroop(totalFee) / opCount`, clamped to -the 100-stroop network minimum, where `opCount = isNew ? 2 : 1`. A 2-op swap then -charges exactly the displayed total. Send is always 1-op (unchanged). The fee +the 100-stroop network minimum, where `opCount = requiresTrustline ? 2 : 1`. A +2-op swap then charges exactly the displayed total. Send is always 1-op +(unchanged). The fee input's recommended default / minimum should scale with `opCount` so the displayed value doesn't jump. @@ -588,8 +590,9 @@ reserve, plus — In [`ReviewTx`](../src/popup/components/InternalTransaction/ReviewTransaction/index.tsx) (shared with Send; gets `dstAsset` for swaps): -- **Trustline banner** — when `destinationTokenDetails.isNew`, render a purple SDS - `Notification` _"This will add a trustline to {CODE}"_ with a chevron → opens +- **Trustline banner** — when `destinationTokenDetails.requiresTrustline`, + render a purple SDS `Notification` _"This will add a trustline to {CODE}"_ with + a chevron → opens **`TrustlineInfoSheet`** (NEW) explaining the 0.5 XLM reserve is one-time and refundable when the trustline is removed ([Figma](https://www.figma.com/design/C3G0a4Gd6RQyplRBppGDsL/Freighter-Extension?node-id=8641-34721)). Add a lilac/`highlight` SDS `Notification` variant if one doesn't exist. @@ -646,8 +649,8 @@ Add new entries to [`constants/metricsNames.ts`](../src/popup/constants/metricsN the empty-state "Select an asset" button, `dropdown` = tapping the token chip in the You sell / You receive card) - **source** selected (`{ tokenCode, tokenIssuer, source: "balances" | "search" }` - — the source picker is held-only, so no `popular` / `isNew`) -- **destination** selected (`{ tokenCode, tokenIssuer, isNew, source: "balances" | "popular" | "search" }`) + — the source picker is held-only, so no `popular` / `requiresTrustline`) +- **destination** selected (`{ tokenCode, tokenIssuer, requiresTrustline, source: "balances" | "popular" | "search" }`) - direction toggled - trustline added (on confirmed combined tx) - XLM-reserve-insufficient shown @@ -671,9 +674,9 @@ verified/unverified info sheets, and the quote-expired message. - `useSwapTokenLookup` ordering/dedupe (held → popular[volume7d ∩ verified] → search remainder), Soroban filtering, `hadSorobanMatches` empty-state, held-only fallback. - - `buildChangeTrustOperation` + the extended `getBuiltTx`: `isNew` produces - `changeTrust` as op[0] and `pathPaymentStrictSend` as op[1]; non-new produces - the single op (regression). + - `buildChangeTrustOperation` + the extended `getBuiltTx`: `requiresTrustline` + produces `changeTrust` as op[0] and `pathPaymentStrictSend` as op[1]; non-new + produces the single op (regression). - Fee total-across-ops: per-op base fee = total/opCount, clamped; 1-op send/swap unchanged. - Quote-expiry classification → expiry path (message + refetch) vs generic fail. From f6d73a49a1dbd66412f103933acee6107720b96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 16:54:07 -0300 Subject: [PATCH 005/121] [Extension] Add destinationTokenDetails to transactionSubmission slice Co-Authored-By: Claude Opus 4.8 --- .../__tests__/transactionSubmission.test.ts | 71 +++++++++++++++++++ .../src/popup/ducks/transactionSubmission.ts | 30 ++++++++ .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + 4 files changed, 105 insertions(+) create mode 100644 extension/src/popup/ducks/__tests__/transactionSubmission.test.ts diff --git a/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts new file mode 100644 index 0000000000..c0de02ec4f --- /dev/null +++ b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts @@ -0,0 +1,71 @@ +import { configureStore } from "@reduxjs/toolkit"; + +import { SecurityLevel } from "popup/constants/blockaid"; +import { + reducer as transactionSubmissionReducer, + saveDestinationTokenDetails, + destinationTokenDetailsSelector, + initialState, + DestinationTokenDetails, +} from "../transactionSubmission"; + +const makeStore = () => + configureStore({ + reducer: { transactionSubmission: transactionSubmissionReducer }, + }); + +describe("transactionSubmission destinationTokenDetails", () => { + it("defaults destinationTokenDetails to null", () => { + const store = makeStore(); + expect(destinationTokenDetailsSelector(store.getState())).toBeNull(); + }); + + it("saves a non-held (requiresTrustline) destination token", () => { + const store = makeStore(); + const details: DestinationTokenDetails = { + tokenCode: "AQUA", + requiresTrustline: true, + decimals: 7, + issuer: "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AB3M", + securityLevel: SecurityLevel.SAFE, + iconUrl: "https://example.test/aqua.png", + }; + + store.dispatch(saveDestinationTokenDetails(details)); + + expect(destinationTokenDetailsSelector(store.getState())).toEqual(details); + }); + + it("saves a held destination token with requiresTrustline false and no issuer (native)", () => { + const store = makeStore(); + const details: DestinationTokenDetails = { + tokenCode: "XLM", + requiresTrustline: false, + decimals: 7, + }; + + store.dispatch(saveDestinationTokenDetails(details)); + + expect(destinationTokenDetailsSelector(store.getState())).toEqual(details); + }); + + it("clears destinationTokenDetails back to null", () => { + const store = makeStore(); + store.dispatch( + saveDestinationTokenDetails({ + tokenCode: "AQUA", + requiresTrustline: true, + decimals: 7, + issuer: "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AB3M", + }), + ); + + store.dispatch(saveDestinationTokenDetails(null)); + + expect(destinationTokenDetailsSelector(store.getState())).toBeNull(); + }); + + it("starts with destinationTokenDetails null in initialState", () => { + expect(initialState.transactionData.destinationTokenDetails).toBeNull(); + }); +}); diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 65b04aa8a6..8b78f68e03 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -28,6 +28,7 @@ import { import { NETWORKS, NetworkDetails } from "@shared/constants/stellar"; import { ConfigurableWalletType } from "@shared/constants/hardwareWallet"; import { isCustomNetwork } from "@shared/helpers/stellar"; +import { SecurityLevel } from "popup/constants/blockaid"; import { getCanonicalFromAsset } from "helpers/stellar"; import { INDEXER_URL } from "@shared/constants/mercury"; @@ -416,6 +417,22 @@ export enum ShowOverlayStatus { IN_PROGRESS = "IN_PROGRESS", } +export interface DestinationTokenDetails { + // e.g. "AQUA" / "XLM" — lets the banner, review rows, and warnings render + // without re-parsing the canonical destinationAsset string. + tokenCode: string; + // true when the user has no trustline for this destination yet. + requiresTrustline: boolean; + // 7 for classic assets. + decimals: number; + // omitted for native XLM. + issuer?: string; + // from the pick-time Blockaid bulk scan. + securityLevel?: SecurityLevel; + // from the search/Popular record, before balances hydrate. + iconUrl?: string; +} + interface TransactionData { amount: string; amountUsd: string; @@ -432,6 +449,7 @@ interface TransactionData { destinationDecimals?: number; destinationAmount: string; destinationIcon: string; + destinationTokenDetails: DestinationTokenDetails | null; path: string[]; allowedSlippage: string; isToken: boolean; @@ -503,6 +521,7 @@ export const initialState: InitialState = { destinationAsset: "", destinationAmount: "", destinationIcon: "", + destinationTokenDetails: null, path: [], allowedSlippage: "1", isCollectible: false, @@ -656,6 +675,12 @@ const transactionSubmissionSlice = createSlice({ state.transactionData.destinationAmount = action.payload.destinationAmount; }, + saveDestinationTokenDetails: ( + state, + action: { payload: DestinationTokenDetails | null }, + ) => { + state.transactionData.destinationTokenDetails = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(submitFreighterTransaction.pending, (state) => { @@ -791,6 +816,7 @@ export const { saveIsMergeSelected, saveBalancesToMigrate, saveSwapBestPath, + saveDestinationTokenDetails, } = transactionSubmissionSlice.actions; export const { reducer } = transactionSubmissionSlice; @@ -805,3 +831,7 @@ export const transactionDataSelector = (state: { export const isPathPaymentSelector = (state: { transactionSubmission: InitialState; }) => state.transactionSubmission.transactionData.destinationAsset !== ""; + +export const destinationTokenDetailsSelector = (state: { + transactionSubmission: InitialState; +}) => state.transactionSubmission.transactionData.destinationTokenDetails; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 52339ec4bb..0ddd30cdd0 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Authorizations", "Authorize": "Authorize", "Authorized address": "Authorized address", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "available", "Back": "Back", "Balance": "Balance", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index db2c569c1c..a0cb327877 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Autorizações", "Authorize": "Autorizar", "Authorized address": "Endereço autorizado", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "disponível", "Back": "Voltar", "Balance": "Saldo", From 3707af73c9feaa4b4544e2b212965071a78ec523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 16:59:17 -0300 Subject: [PATCH 006/121] [Extension] Default swap slippage to 2% to match mobile Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/swap/SwapAmount/index.tsx | 2 +- .../src/popup/ducks/__tests__/transactionSubmission.test.ts | 6 ++++++ extension/src/popup/ducks/transactionSubmission.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 61ece368b4..9c1d4120ae 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -58,7 +58,7 @@ import { AssetTile } from "popup/components/AssetTile"; import "./styles.scss"; -const defaultSlippage = "1"; +const defaultSlippage = "2"; const DEFAULT_INPUT_WIDTH = 25; interface SwapAmountProps { diff --git a/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts index c0de02ec4f..26807c846d 100644 --- a/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts +++ b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts @@ -69,3 +69,9 @@ describe("transactionSubmission destinationTokenDetails", () => { expect(initialState.transactionData.destinationTokenDetails).toBeNull(); }); }); + +describe("transactionSubmission slippage default", () => { + it('defaults allowedSlippage to "2" (matching mobile)', () => { + expect(initialState.transactionData.allowedSlippage).toBe("2"); + }); +}); diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 8b78f68e03..db4b925ba1 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -523,7 +523,7 @@ export const initialState: InitialState = { destinationIcon: "", destinationTokenDetails: null, path: [], - allowedSlippage: "1", + allowedSlippage: "2", isCollectible: false, collectibleData: { collectionName: "", From c6b49815589f7635e91260df06fb14f0da6f336e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 17:02:41 -0300 Subject: [PATCH 007/121] [Extension] Add shared PercentageButtons component Co-Authored-By: Claude Opus 4.8 --- .../__tests__/index.test.tsx | 23 ++++++++++ .../amount/PercentageButtons/index.tsx | 46 +++++++++++++++++++ .../amount/PercentageButtons/styles.scss | 26 +++++++++++ 3 files changed, 95 insertions(+) create mode 100644 extension/src/popup/components/amount/PercentageButtons/__tests__/index.test.tsx create mode 100644 extension/src/popup/components/amount/PercentageButtons/index.tsx create mode 100644 extension/src/popup/components/amount/PercentageButtons/styles.scss diff --git a/extension/src/popup/components/amount/PercentageButtons/__tests__/index.test.tsx b/extension/src/popup/components/amount/PercentageButtons/__tests__/index.test.tsx new file mode 100644 index 0000000000..0e4ab4032f --- /dev/null +++ b/extension/src/popup/components/amount/PercentageButtons/__tests__/index.test.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { PercentageButtons } from "popup/components/amount/PercentageButtons"; + +describe("PercentageButtons", () => { + it("renders 25/50/75/Max and fires onSelect with the right percentage", () => { + const onSelect = jest.fn(); + render(); + + expect(screen.getByText("25%")).toBeInTheDocument(); + expect(screen.getByText("50%")).toBeInTheDocument(); + expect(screen.getByText("75%")).toBeInTheDocument(); + expect(screen.getByTestId("SendAmountSetMax")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("25%")); + fireEvent.click(screen.getByText("50%")); + fireEvent.click(screen.getByText("75%")); + fireEvent.click(screen.getByTestId("SendAmountSetMax")); + + expect(onSelect.mock.calls.map((c) => c[0])).toEqual([25, 50, 75, 100]); + }); +}); diff --git a/extension/src/popup/components/amount/PercentageButtons/index.tsx b/extension/src/popup/components/amount/PercentageButtons/index.tsx new file mode 100644 index 0000000000..6e8c248dfd --- /dev/null +++ b/extension/src/popup/components/amount/PercentageButtons/index.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import "./styles.scss"; + +const PERCENTAGE_OPTIONS = [ + ["25%", 25], + ["50%", 50], + ["75%", 75], +] as const; + +export interface PercentageButtonsProps { + onSelect: (pct: number) => void; +} + +export const PercentageButtons = ({ onSelect }: PercentageButtonsProps) => { + const { t } = useTranslation(); + return ( +
+ {PERCENTAGE_OPTIONS.map(([label, pct]) => ( + + ))} + +
+ ); +}; diff --git a/extension/src/popup/components/amount/PercentageButtons/styles.scss b/extension/src/popup/components/amount/PercentageButtons/styles.scss new file mode 100644 index 0000000000..3cd8aa3997 --- /dev/null +++ b/extension/src/popup/components/amount/PercentageButtons/styles.scss @@ -0,0 +1,26 @@ +@use "../../../styles/utils.scss" as *; + +.PercentageButtons { + display: flex; + gap: pxToRem(8px); + margin-bottom: pxToRem(16px); + + &__btn { + flex: 1; + padding: pxToRem(8px) 0; + border: 1px solid var(--sds-clr-gray-06); + border-radius: pxToRem(20px); + background: none; + color: var(--sds-clr-gray-12); + font-size: pxToRem(13px); + cursor: pointer; + + &:hover { + background-color: var(--sds-clr-gray-03); + } + + &:active { + background-color: var(--sds-clr-gray-04); + } + } +} From 47a094b01f16d2f255b476d6556005cc32f25f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 17:08:53 -0300 Subject: [PATCH 008/121] [Extension] Add shared AmountCard component with optional security badge Co-Authored-By: Claude Sonnet 4.6 --- .../AmountCard/__tests__/index.test.tsx | 76 ++++++ .../components/amount/AmountCard/index.tsx | 250 ++++++++++++++++++ .../components/amount/AmountCard/styles.scss | 172 ++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx create mode 100644 extension/src/popup/components/amount/AmountCard/index.tsx create mode 100644 extension/src/popup/components/amount/AmountCard/styles.scss diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx new file mode 100644 index 0000000000..593af6d9cc --- /dev/null +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { Wrapper } from "popup/__testHelpers__"; +import { AmountCard } from "popup/components/amount/AmountCard"; +import { SecurityLevel } from "popup/constants/blockaid"; + +const baseProps = { + label: "Sending", + availableBalanceText: "100 XLM available", + availableBalanceFontSizePx: 14, + inputType: "crypto" as const, + amount: "5", + amountUsd: "0.00", + amountFontSizeClass: "lg" as const, + assetCode: "XLM", + assetIcon: null, + assetIcons: {}, + assetIssuerKey: undefined, + supportsUsd: false, + fiatLineText: "", + isAmountTooHigh: false, + cryptoDecimals: 7, + onAmountChange: jest.fn(), + onAmountUsdChange: jest.fn(), + onToggleInputType: jest.fn(), + onSelectAsset: jest.fn(), +}; + +describe("AmountCard", () => { + it("renders the label, balance line and asset code", () => { + render( + + + , + ); + expect(screen.getByText("Sending")).toBeInTheDocument(); + expect(screen.getByText("100 XLM available")).toBeInTheDocument(); + expect(screen.getByText("XLM")).toBeInTheDocument(); + }); + + it("fires onAmountChange when the crypto input changes", () => { + const onAmountChange = jest.fn(); + render( + + + , + ); + fireEvent.change(screen.getByTestId("send-amount-amount-input"), { + target: { value: "12" }, + }); + expect(onAmountChange).toHaveBeenCalledWith( + expect.objectContaining({ amount: "12" }), + ); + }); + + it("overlays the scam-asset badge when securityLevel is MALICIOUS", () => { + render( + + + , + ); + expect(screen.getByTestId("ScamAssetIcon")).toBeInTheDocument(); + }); + + it("renders the too-high error when isAmountTooHigh is true", () => { + render( + + + , + ); + expect( + screen.getByText("You don’t have enough {{asset}} in your account"), + ).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx new file mode 100644 index 0000000000..1289a1c70a --- /dev/null +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -0,0 +1,250 @@ +import React, { useLayoutEffect, useRef, useState } from "react"; +import { Button, Icon } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { AssetIcons } from "@shared/api/types"; +import { SecurityLevel } from "popup/constants/blockaid"; +import { InputType } from "helpers/transaction"; +import { formatAmountPreserveCursor } from "popup/helpers/formatters"; +import { useRunAfterUpdate } from "popup/helpers/useRunAfterUpdate"; + +import "./styles.scss"; + +const DEFAULT_INPUT_WIDTH = 25; + +export interface AmountCardProps { + label: string; + availableBalanceText: string; + availableBalanceFontSizePx: number; + inputType: InputType; + amount: string; + amountUsd: string; + amountFontSizeClass: "lg" | "med" | "small" | "xsmall"; + assetCode: string; + assetIcon?: string | null; + assetIcons: AssetIcons; + assetIssuerKey?: string; + securityLevel?: SecurityLevel; + supportsUsd: boolean; + fiatLineText: string; + isAmountTooHigh: boolean; + isReadOnly?: boolean; + autoFocus?: boolean; + cryptoDecimals: number; + onAmountChange: (next: { amount: string; newCursor: number }) => void; + onAmountUsdChange: (next: { amount: string; newCursor: number }) => void; + onToggleInputType: () => void; + onSelectAsset: () => void; +} + +export const AmountCard = ({ + label, + availableBalanceText, + availableBalanceFontSizePx, + inputType, + amount, + amountUsd, + amountFontSizeClass, + assetCode, + assetIcon, + assetIcons, + assetIssuerKey, + securityLevel, + supportsUsd, + fiatLineText, + isAmountTooHigh, + isReadOnly = false, + autoFocus = true, + cryptoDecimals, + onAmountChange, + onAmountUsdChange, + onToggleInputType, + onSelectAsset, +}: AmountCardProps) => { + const { t } = useTranslation(); + const runAfterUpdate = useRunAfterUpdate(); + + // Width owned internally (replaces InputWidthContext, per design §3.3). + const cryptoSpanRef = useRef(null); + const fiatSpanRef = useRef(null); + const [inputWidthCrypto, setInputWidthCrypto] = useState(0); + const [inputWidthFiat, setInputWidthFiat] = useState(0); + + useLayoutEffect(() => { + if (cryptoSpanRef.current) { + setInputWidthCrypto(cryptoSpanRef.current.offsetWidth + 2); + } + }, [amount]); + + useLayoutEffect(() => { + if (fiatSpanRef.current) { + setInputWidthFiat(fiatSpanRef.current.offsetWidth + 4); + } + }, [amountUsd]); + + const isSuspicious = + securityLevel === SecurityLevel.MALICIOUS || + securityLevel === SecurityLevel.SUSPICIOUS; + + const fontClass = `AmountCard__input-amount AmountCard__${amountFontSizeClass}`; + + return ( +
+
+ {label} + + {availableBalanceText} + +
+ +
+
+ {inputType === "crypto" && ( + <> + + {amount || "0"} + + { + const input = e.target; + const next = formatAmountPreserveCursor( + e.target.value, + amount, + cryptoDecimals, + e.target.selectionStart || 1, + ); + onAmountChange(next); + runAfterUpdate(() => { + input.selectionStart = next.newCursor; + input.selectionEnd = next.newCursor; + }); + }} + autoFocus={autoFocus} + autoComplete="off" + /> + + )} + {inputType === "fiat" && ( + <> +
+ $ +
+ + {amountUsd || "0"} + + { + const input = e.target; + const next = formatAmountPreserveCursor( + e.target.value, + amountUsd, + 2, + e.target.selectionStart || 1, + ); + onAmountUsdChange(next); + runAfterUpdate(() => { + input.selectionStart = next.newCursor; + input.selectionEnd = next.newCursor; + }); + }} + autoFocus={autoFocus} + autoComplete="off" + onFocus={(e) => e.target.select()} + /> + + )} +
+ +
+ + {supportsUsd && ( +
+
+ {fiatLineText} + +
+
+ )} + + {isAmountTooHigh && ( +
+ + + {t("You don’t have enough {{asset}} in your account", { + asset: assetCode, + })} + +
+ )} +
+ ); +}; diff --git a/extension/src/popup/components/amount/AmountCard/styles.scss b/extension/src/popup/components/amount/AmountCard/styles.scss new file mode 100644 index 0000000000..be0d52e7a9 --- /dev/null +++ b/extension/src/popup/components/amount/AmountCard/styles.scss @@ -0,0 +1,172 @@ +@use "../../../styles/utils.scss" as *; + +.AmountCard { + background-color: var(--sds-clr-gray-03); + border-radius: pxToRem(16px); + padding: pxToRem(12px) pxToRem(16px); + display: flex; + flex-direction: column; + width: 100%; + margin-top: pxToRem(8px); + margin-bottom: pxToRem(8px); + + &__sending-label { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: pxToRem(8px); + font-size: pxToRem(14px); + line-height: pxToRem(20px); + color: var(--sds-clr-gray-11); + margin-bottom: pxToRem(8px); + + & > span { + text-box-trim: trim-end; + } + } + + &__available-balance { + color: var(--sds-clr-gray-11); + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + text-box-trim: trim-end; + } + + &__amount-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(12px); + position: relative; + flex-wrap: nowrap; + } + + &__amount-input-container { + flex: 1 1 auto; + min-width: 0; + display: flex; + justify-content: flex-start; + align-items: center; + overflow: hidden; + } + + &__amount-label-usd { + font-size: pxToRem(24px); + line-height: 1; + font-weight: var(--font-weight-medium); + color: var(--sds-clr-gray-12); + flex-shrink: 0; + } + + &__input-amount { + font-size: pxToRem(24px); + line-height: 1; + font-weight: var(--font-weight-medium); + color: var(--sds-clr-gray-12); + background: none; + border: none; + text-align: left; + background-color: transparent; + outline: none; + padding: 0; + } + + &__lg { + font-size: pxToRem(24px); + } + + &__small { + font-size: pxToRem(16px); + } + + &__med { + font-size: pxToRem(19px); + } + + &__xsmall { + font-size: pxToRem(14px); + } + + &__asset-selector-inline { + display: flex; + align-items: center; + gap: pxToRem(4px); + cursor: pointer; + padding: pxToRem(4px) pxToRem(8px) pxToRem(4px) pxToRem(4px); + border-radius: pxToRem(20px); + background-color: var(--sds-clr-gray-01); + white-space: nowrap; + flex-shrink: 0; + margin-bottom: 0; + border: 0; + color: var(--sds-clr-gray-12); + + &:hover { + background-color: var(--sds-clr-gray-06); + } + + .AccountAssets__asset--logo { + width: pxToRem(20px) !important; + height: pxToRem(20px) !important; + min-width: pxToRem(20px); + margin: pxToRem(4px); + + img { + width: 100%; + height: 100%; + } + } + + svg:last-child { + width: pxToRem(14px); + height: pxToRem(14px); + } + } + + &__asset-code { + font-size: pxToRem(14px); + font-weight: var(--font-weight-medium); + } + + &__balance-row { + display: flex; + align-items: center; + gap: pxToRem(8px); + margin-top: pxToRem(6px); + } + + &__amount-price { + display: flex; + justify-content: flex-start; + align-items: center; + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + flex: 0 0 auto; + white-space: nowrap; + + .Button { + border: 0; + padding: 0; + width: 14px; + height: 14px; + margin-left: pxToRem(6px); + flex-shrink: 0; + } + } + + &__invalid-state { + display: flex; + justify-content: center; + margin-top: pxToRem(8px); + color: var(--sds-clr-red-09); + font-size: pxToRem(14px); + + svg { + margin-right: pxToRem(6px); + } + } +} From 190e79e1f6fd019c4315f482520f3aff6945cbf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 17:26:04 -0300 Subject: [PATCH 009/121] [Extension] Migrate SendAmount to shared AmountCard + PercentageButtons Co-Authored-By: Claude Sonnet 4.6 --- .../components/send/SendAmount/index.tsx | 329 ++++-------------- 1 file changed, 64 insertions(+), 265 deletions(-) diff --git a/extension/src/popup/components/send/SendAmount/index.tsx b/extension/src/popup/components/send/SendAmount/index.tsx index ea5c0c92dd..99065c78a9 100644 --- a/extension/src/popup/components/send/SendAmount/index.tsx +++ b/extension/src/popup/components/send/SendAmount/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Navigate, useLocation } from "react-router-dom"; import BigNumber from "bignumber.js"; @@ -18,7 +18,6 @@ import { import { NetworkCongestion } from "popup/helpers/useNetworkFees"; import { emitMetric } from "helpers/metrics"; import { trackSendFeeBreakdownOpened } from "popup/metrics/send"; -import { useRunAfterUpdate } from "popup/helpers/useRunAfterUpdate"; import { getAssetDecimals, getAvailableBalance, @@ -28,7 +27,6 @@ import { SubviewHeader } from "popup/components/SubviewHeader"; import { cleanAmount, formatAmount, - formatAmountPreserveCursor, getValidBigNumber, isValidPositiveAmount, normalizeNumericString, @@ -54,7 +52,6 @@ import { openTab } from "popup/helpers/navigate"; import { newTabHref } from "helpers/urls"; import { AMOUNT_ERROR, InputType } from "helpers/transaction"; import { reRouteOnboarding } from "popup/helpers/route"; -import { AssetIcon } from "popup/components/account/AccountAssets"; import { EditSettings } from "popup/components/InternalTransaction/EditSettings"; import { FeesPane } from "popup/components/InternalTransaction/FeesPane"; import { EditMemo } from "popup/components/InternalTransaction/EditMemo"; @@ -67,7 +64,8 @@ import { useGetSendAmountData } from "./hooks/useSendAmountData"; import { SimulateTxData, SimulateResult } from "./hooks/useSimulateTxData"; import { SlideupModal } from "popup/components/SlideupModal"; import { MemoEditingContext } from "popup/constants/send-payment"; -import { InputWidthContext } from "popup/views/Send/contexts/inputWidthContext"; +import { AmountCard } from "popup/components/amount/AmountCard"; +import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import { checkIsMuxedSupported, getMemoDisabledState, @@ -77,13 +75,6 @@ import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import "../styles.scss"; -const DEFAULT_INPUT_WIDTH = 25; -const PERCENTAGE_OPTIONS = [ - ["25%", 25], - ["50%", 50], - ["75%", 75], -] as const; - const AVAILABLE_BALANCE_FONT_SIZES = [ { maxLen: 28, sizePx: 14 }, { maxLen: 42, sizePx: 12 }, @@ -195,18 +186,6 @@ export const SendAmount = ({ // can detect changes and re-simulate without watching simulationState.data. const simulationDataRef = useRef({ destination: "", asset: "" }); - const cryptoSpanRef = useRef(null); - const fiatSpanRef = useRef(null); - const cryptoInputRef = useRef(null); - const usdInputRef = useRef(null); - const runAfterUpdate = useRunAfterUpdate(); - const { - inputWidthCrypto, - setInputWidthCrypto, - inputWidthFiat, - setInputWidthFiat, - } = React.useContext(InputWidthContext); - const [inputType, setInputType] = useState("crypto"); const [isEditingMemo, setIsEditingMemo] = React.useState(false); const [isEditingSettings, setIsEditingSettings] = React.useState(false); @@ -390,18 +369,6 @@ export const SendAmount = ({ validateOnChange: true, }); - useLayoutEffect(() => { - if (cryptoSpanRef.current) { - setInputWidthCrypto(cryptoSpanRef.current.offsetWidth + 2); - } - }, [formik.values.amount, setInputWidthCrypto]); - - useLayoutEffect(() => { - if (fiatSpanRef.current) { - setInputWidthFiat(fiatSpanRef.current.offsetWidth + 4); - } - }, [formik.values.amountUsd, setInputWidthFiat]); - const srcAsset = getAssetFromCanonical(asset); const parsedSourceAsset = getAssetFromCanonical(formik.values.asset); const isLoading = @@ -819,238 +786,70 @@ export const SendAmount = ({ onClick={goToChooseDest} /> - {/* Amount card: matches mobile's rounded card container */} -
- {/* Sending label + available balance */} -
- {t("Sending")} - - {availableBalanceText} - -
- - {/* Amount row: input + inline asset selector */} -
-
- {inputType === "crypto" && ( - <> - - {formik.values.amount || "0"} - - { - const input = e.target; - const { amount: newAmount, newCursor } = - formatAmountPreserveCursor( - e.target.value, - formik.values.amount, - getAssetDecimals( - asset, - sendData.userBalances, - isToken, - ), - e.target.selectionStart || 1, - ); - formik.setFieldValue("amount", newAmount); - dispatch(saveAmount(newAmount)); - setEditedInputType("crypto"); - runAfterUpdate(() => { - input.selectionStart = newCursor; - input.selectionEnd = newCursor; - }); - }} - autoFocus - autoComplete="off" - /> - - )} - {inputType === "fiat" && ( - <> -
- $ -
- - {formik.values.amountUsd || "0"} - - { - const input = e.target; - const { amount: newAmount, newCursor } = - formatAmountPreserveCursor( - e.target.value, - formik.values.amountUsd, - 2, - e.target.selectionStart || 1, - ); - formik.setFieldValue("amountUsd", newAmount); - dispatch(saveAmountUsd(newAmount)); - setEditedInputType("fiat"); - runAfterUpdate(() => { - input.selectionStart = newCursor; - input.selectionEnd = newCursor; - }); - }} - autoFocus - autoComplete="off" - onFocus={(e) => e.target.select()} - /> - - )} -
- -
- - {/* Secondary row: USD equivalent + swap toggle */} - {supportsUsd && ( -
-
- {inputType === "crypto" - ? `$${priceValueUsd || "0.00"}` - : `${formatAmount(effectiveTokenAmount || "0")} ${parsedSourceAsset.code}`} - -
-
- )} - - {/* Error state */} - {isAmountTooHigh && ( -
- - - {t( - "You don’t have enough {{asset}} in your account", - { - asset: parsedSourceAsset.code, - }, - )} - -
+ {/* Amount card */} + + onAmountChange={({ amount: newAmount }) => { + formik.setFieldValue("amount", newAmount); + dispatch(saveAmount(newAmount)); + setEditedInputType("crypto"); + }} + onAmountUsdChange={({ amount: newAmount }) => { + formik.setFieldValue("amountUsd", newAmount); + dispatch(saveAmountUsd(newAmount)); + setEditedInputType("fiat"); + }} + onToggleInputType={() => { + const newInputType = + inputType === "crypto" ? "fiat" : "crypto"; + if (newInputType === "crypto") { + const converted = + editedInputType === "crypto" + ? formik.values.amount || "0" + : (priceValue ?? "0"); + dispatch(saveAmount(converted)); + formik.setFieldValue("amount", converted); + } + if (newInputType === "fiat") { + const raw = + editedInputType === "fiat" + ? formik.values.amountUsd || "0" + : (priceValueUsd ?? "0"); + const converted = raw === "0.00" ? "0" : raw; + dispatch(saveAmountUsd(converted)); + formik.setFieldValue("amountUsd", converted); + } + setInputType(newInputType); + }} + onSelectAsset={goToChooseAssetAction} + /> {/* Percentage buttons */} -
- {PERCENTAGE_OPTIONS.map(([label, pct]) => ( - - ))} - -
+
)} From adf9578b71083e6d6fa7b7bcef82b08c5d816de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 17:29:40 -0300 Subject: [PATCH 010/121] [Extension] Remove Send-local InputWidthContext now that AmountCard owns width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../views/Send/contexts/inputWidthContext.tsx | 37 ------------------- extension/src/popup/views/Send/index.tsx | 3 +- 2 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 extension/src/popup/views/Send/contexts/inputWidthContext.tsx diff --git a/extension/src/popup/views/Send/contexts/inputWidthContext.tsx b/extension/src/popup/views/Send/contexts/inputWidthContext.tsx deleted file mode 100644 index 504c71ea6d..0000000000 --- a/extension/src/popup/views/Send/contexts/inputWidthContext.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { createContext, useState } from "react"; - -interface InputWidthContextType { - inputWidthCrypto: number; - setInputWidthCrypto: React.Dispatch>; - inputWidthFiat: number; - setInputWidthFiat: React.Dispatch>; -} - -export const InputWidthContext = createContext({ - inputWidthCrypto: 0, - setInputWidthCrypto: () => {}, - inputWidthFiat: 0, - setInputWidthFiat: () => {}, -}); - -export const InputWidthProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [inputWidthCrypto, setInputWidthCrypto] = useState(0); - const [inputWidthFiat, setInputWidthFiat] = useState(0); - - return ( - - {children} - - ); -}; diff --git a/extension/src/popup/views/Send/index.tsx b/extension/src/popup/views/Send/index.tsx index 7d919f91f7..05139dc0af 100644 --- a/extension/src/popup/views/Send/index.tsx +++ b/extension/src/popup/views/Send/index.tsx @@ -30,7 +30,6 @@ import { RequestState } from "constants/request"; import { View } from "popup/basics/layout/View"; import { useSendQueryParams } from "./hooks/useSendQueryParams"; -import { InputWidthProvider } from "./contexts/inputWidthContext"; import "./styles.scss"; @@ -329,7 +328,7 @@ export const Send = () => { }`} aria-hidden={!isActive} > - {renderStep(step)} + {renderStep(step)} ); })} From eec672ded8b0c8bf42f4b0e92bc310a718f9c0df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 17:33:30 -0300 Subject: [PATCH 011/121] [Extension] Add fetchTrendingAssets stellar.expert helper for swap Popular tokens Co-Authored-By: Claude Opus 4.8 --- .../helpers/__tests__/trendingAssets.test.ts | 101 ++++++++++++++++++ extension/src/popup/helpers/trendingAssets.ts | 70 ++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 extension/src/popup/helpers/__tests__/trendingAssets.test.ts create mode 100644 extension/src/popup/helpers/trendingAssets.ts diff --git a/extension/src/popup/helpers/__tests__/trendingAssets.test.ts b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts new file mode 100644 index 0000000000..5255b150c8 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts @@ -0,0 +1,101 @@ +import { + fetchTrendingAssets, + MIN_TRENDING_VOLUME7D, + TRENDING_LIMIT, +} from "../trendingAssets"; +import { NetworkDetails } from "@shared/constants/stellar"; + +const MAINNET: NetworkDetails = { + network: "PUBLIC", + networkName: "Main Net", + networkUrl: "https://horizon.stellar.org", + networkPassphrase: "Public Global Stellar Network ; September 2015", + sorobanRpcUrl: "https://soroban.stellar.org", +} as NetworkDetails; + +const TESTNET: NetworkDetails = { + network: "TESTNET", + networkName: "Test Net", + networkUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", +} as NetworkDetails; + +const recordsResponse = ( + records: { asset: string; volume7d: number; domain?: string }[], +) => ({ + json: async () => ({ _embedded: { records } }), + ok: true, +}); + +describe("fetchTrendingAssets", () => { + afterEach(() => jest.restoreAllMocks()); + + it("hits the volume7d-sorted endpoint with limit=50 on mainnet", async () => { + const fetchSpy = jest + .spyOn(global, "fetch") + .mockResolvedValue( + recordsResponse([ + { asset: "AQUA-GBNZ", volume7d: MIN_TRENDING_VOLUME7D + 1 }, + ]) as unknown as Response, + ); + + await fetchTrendingAssets({ networkDetails: MAINNET }); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain("api.stellar.expert/explorer/public/asset"); + expect(calledUrl).toContain("sort=volume7d"); + expect(calledUrl).toContain("order=desc"); + expect(calledUrl).toContain(`limit=${TRENDING_LIMIT}`); + }); + + it("omits sort/order params on testnet", async () => { + const fetchSpy = jest + .spyOn(global, "fetch") + .mockResolvedValue( + recordsResponse([ + { asset: "USDC-GTEST", volume7d: 0 }, + ]) as unknown as Response, + ); + + await fetchTrendingAssets({ networkDetails: TESTNET }); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain("api.stellar.expert/explorer/testnet/asset"); + expect(calledUrl).not.toContain("sort=volume7d"); + expect(calledUrl).not.toContain("order=desc"); + expect(calledUrl).toContain(`limit=${TRENDING_LIMIT}`); + }); + + it("drops mainnet records below the volume floor", async () => { + jest.spyOn(global, "fetch").mockResolvedValue( + recordsResponse([ + { asset: "BIG-GBIG", volume7d: MIN_TRENDING_VOLUME7D + 5 }, + { asset: "SMALL-GSMALL", volume7d: MIN_TRENDING_VOLUME7D - 5 }, + ]) as unknown as Response, + ); + + const result = await fetchTrendingAssets({ networkDetails: MAINNET }); + + expect(result.map((r) => r.code)).toEqual(["BIG"]); + expect(result[0].issuer).toBe("GBIG"); + }); + + it("keeps every testnet record regardless of volume (floor is a no-op)", async () => { + jest.spyOn(global, "fetch").mockResolvedValue( + recordsResponse([ + { asset: "A-GA", volume7d: 0 }, + { asset: "B-GB", volume7d: 0 }, + ]) as unknown as Response, + ); + + const result = await fetchTrendingAssets({ networkDetails: TESTNET }); + expect(result.map((r) => r.code)).toEqual(["A", "B"]); + }); + + it("returns [] when the fetch rejects", async () => { + jest.spyOn(global, "fetch").mockRejectedValue(new Error("network down")); + const result = await fetchTrendingAssets({ networkDetails: MAINNET }); + expect(result).toEqual([]); + }); +}); diff --git a/extension/src/popup/helpers/trendingAssets.ts b/extension/src/popup/helpers/trendingAssets.ts new file mode 100644 index 0000000000..946a41b4ba --- /dev/null +++ b/extension/src/popup/helpers/trendingAssets.ts @@ -0,0 +1,70 @@ +import { NetworkDetails } from "@shared/constants/stellar"; +import { getApiStellarExpertUrl } from "popup/helpers/account"; +import { isTestnet } from "helpers/stellar"; + +export const TRENDING_LIMIT = 50; +// Mainnet-only floor mirroring mobile's MIN_TRENDING_VOLUME7D. stellar.expert +// reports volume7d in USD scaled by 10^7 (so 70_000_000_000 ≈ $7,000 USD/week); +// this filters out dust-volume assets before they reach the Popular list. +export const MIN_TRENDING_VOLUME7D = 70_000_000_000; + +export interface TrendingAsset { + code: string; + issuer: string; + contract?: string; + domain: string | null; + icon?: string; + volume7d: number; +} + +interface TrendingRecord { + asset: string; // "CODE-ISSUER" for classic, contract id otherwise + volume7d?: number; + domain?: string; + tomlInfo?: { image?: string; code?: string }; +} + +export const fetchTrendingAssets = async ({ + networkDetails, + signal, +}: { + networkDetails: NetworkDetails; + signal?: AbortSignal; +}): Promise => { + const base = `${getApiStellarExpertUrl(networkDetails)}/asset`; + const testnet = isTestnet(networkDetails); + // On testnet volume7d is always 0, so sorting by it is meaningless — omit the + // sort/order params and accept the API's default order. + const sortParams = testnet + ? `limit=${TRENDING_LIMIT}` + : `sort=volume7d&order=desc&limit=${TRENDING_LIMIT}`; + + try { + const res = await fetch(`${base}?${sortParams}`, { signal }); + if (!res.ok) { + return []; + } + const json = await res.json(); + const records: TrendingRecord[] = json?._embedded?.records ?? []; + + const applyFloor = !testnet; + + return records + .filter((record) => record.asset.includes("-")) // classic only; contract ids dropped here, SAC handled in the hook + .map((record): TrendingAsset => { + const [code, issuer] = record.asset.split("-"); + return { + code, + issuer, + domain: record.domain ?? null, + icon: record.tomlInfo?.image, + volume7d: record.volume7d ?? 0, + }; + }) + .filter((asset) => + applyFloor ? asset.volume7d >= MIN_TRENDING_VOLUME7D : true, + ); + } catch (e) { + return []; + } +}; From f228bcbe73fcac7614104ec473bd2bf839b7d59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 17:42:38 -0300 Subject: [PATCH 012/121] [Extension] Add Redux cache slots for swap Popular tokens + asset scan results Co-Authored-By: Claude Opus 4.8 --- .../ducks/__tests__/cache.popular.test.ts | 66 +++++++++++++++++++ extension/src/popup/ducks/cache.ts | 64 +++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 extension/src/popup/ducks/__tests__/cache.popular.test.ts diff --git a/extension/src/popup/ducks/__tests__/cache.popular.test.ts b/extension/src/popup/ducks/__tests__/cache.popular.test.ts new file mode 100644 index 0000000000..580f7164ab --- /dev/null +++ b/extension/src/popup/ducks/__tests__/cache.popular.test.ts @@ -0,0 +1,66 @@ +import { + reducer, + savePopularTokens, + saveAssetScanResults, + clearAll, +} from "popup/ducks/cache"; +import { NetworkDetails } from "@shared/constants/stellar"; + +const MAINNET = { network: "PUBLIC" } as NetworkDetails; + +const trending = [{ code: "AQUA", issuer: "GBNZ", domain: null, volume7d: 5 }]; + +describe("cache slice — popular tokens + scan results", () => { + it("stamps popularTokens with updatedAt per network", () => { + const before = Date.now(); + const state = reducer( + undefined, + savePopularTokens({ networkDetails: MAINNET, tokens: trending }), + ); + expect(state.popularTokens.PUBLIC.tokens).toEqual(trending); + expect(state.popularTokens.PUBLIC.updatedAt).toBeGreaterThanOrEqual(before); + }); + + it("stamps assetScanResults with updatedAt per network", () => { + const results = { "AQUA-GBNZ": { result_type: "Benign" } } as any; + const state = reducer( + undefined, + saveAssetScanResults({ networkDetails: MAINNET, results }), + ); + expect(state.assetScanResults.PUBLIC.results["AQUA-GBNZ"]).toEqual({ + result_type: "Benign", + }); + expect(state.assetScanResults.PUBLIC.updatedAt).toBeGreaterThan(0); + }); + + it("merges new scan results into the existing per-network map", () => { + let state = reducer( + undefined, + saveAssetScanResults({ + networkDetails: MAINNET, + results: { "A-G": { result_type: "Benign" } } as any, + }), + ); + state = reducer( + state, + saveAssetScanResults({ + networkDetails: MAINNET, + results: { "B-G": { result_type: "Malicious" } } as any, + }), + ); + expect(Object.keys(state.assetScanResults.PUBLIC.results).sort()).toEqual([ + "A-G", + "B-G", + ]); + }); + + it("clearAll resets popularTokens and assetScanResults", () => { + let state = reducer( + undefined, + savePopularTokens({ networkDetails: MAINNET, tokens: trending }), + ); + state = reducer(state, clearAll()); + expect(state.popularTokens).toEqual({}); + expect(state.assetScanResults).toEqual({}); + }); +}); diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 93ccaeaca0..ce4ac79117 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -4,7 +4,12 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; import { HistoryResponse } from "helpers/hooks/useGetHistory"; import { TokenDetailsResponse } from "helpers/hooks/useTokenDetails"; -import { ApiTokenPrices, Collection } from "@shared/api/types"; +import { + ApiTokenPrices, + BlockAidScanAssetResult, + Collection, +} from "@shared/api/types"; +import { TrendingAsset } from "popup/helpers/trendingAssets"; type AssetCode = string; type PublicKey = string; @@ -52,6 +57,19 @@ type SaveCollectionsPayload = { collections: Collection[]; }; +type SavePopularTokensPayload = { + networkDetails: NetworkDetails; + tokens: TrendingAsset[]; +}; + +type SaveAssetScanResultsPayload = { + networkDetails: NetworkDetails; + results: Record; +}; + +// ~30-min staleness window for the Popular list + scan-result Redux cache (§2.8). +export const POPULAR_TOKENS_STALE_MS = 30 * 60 * 1000; + interface InitialState { balanceData: { [network: string]: Record< @@ -72,6 +90,15 @@ interface InitialState { [publicKey: string]: ApiTokenPrices & { updatedAt: number }; }; collections: { [network: string]: Record }; + popularTokens: { + [network: string]: { tokens: TrendingAsset[]; updatedAt: number }; + }; + assetScanResults: { + [network: string]: { + results: Record; + updatedAt: number; + }; + }; } const initialState: InitialState = { @@ -83,6 +110,8 @@ const initialState: InitialState = { historyData: {}, tokenPrices: {}, collections: {}, + popularTokens: {}, + assetScanResults: {}, }; const cacheSlice = createSlice({ @@ -97,6 +126,8 @@ const cacheSlice = createSlice({ state.tokenDetails = {}; state.historyData = {}; state.tokenPrices = {}; + state.popularTokens = {}; + state.assetScanResults = {}; }, saveBalancesForAccount(state, action: { payload: SaveBalancesPayload }) { state.balanceData = { @@ -179,6 +210,31 @@ const cacheSlice = createSlice({ ]; } }, + savePopularTokens(state, action: { payload: SavePopularTokensPayload }) { + state.popularTokens = { + ...state.popularTokens, + [action.payload.networkDetails.network]: { + tokens: action.payload.tokens, + updatedAt: Date.now(), + }, + }; + }, + saveAssetScanResults( + state, + action: { payload: SaveAssetScanResultsPayload }, + ) { + const network = action.payload.networkDetails.network; + state.assetScanResults = { + ...state.assetScanResults, + [network]: { + results: { + ...(state.assetScanResults[network]?.results || {}), + ...action.payload.results, + }, + updatedAt: Date.now(), + }, + }; + }, }, }); @@ -200,6 +256,10 @@ export const selectBalancesByPublicKey = (publicKey: string) => createSelector(balancesSelector, (balances) => balances[publicKey]); export const collectionsSelector = (state: { cache: InitialState }) => state.cache.collections; +export const popularTokensSelector = (state: { cache: InitialState }) => + state.cache.popularTokens; +export const assetScanResultsSelector = (state: { cache: InitialState }) => + state.cache.assetScanResults; export const { reducer } = cacheSlice; export const { @@ -214,4 +274,6 @@ export const { saveCollections, clearBalancesForAccount, clearCollectiblesForAccount, + savePopularTokens, + saveAssetScanResults, } = cacheSlice.actions; From dc8050fe7191957e3e39b8d2fadd64ec4b6ff8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 17:52:11 -0300 Subject: [PATCH 013/121] [Extension] Add useSwapTokenLookup destination-discovery hook for swap picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/useSwapTokenLookup.test.ts | 136 +++++ .../SwapAsset/hooks/useSwapTokenLookup.ts | 547 ++++++++++++++++++ 2 files changed, 683 insertions(+) create mode 100644 extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts create mode 100644 extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts new file mode 100644 index 0000000000..cd04f951ec --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts @@ -0,0 +1,136 @@ +import { buildSwapSections, mergeScanResults } from "../useSwapTokenLookup"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { SecurityLevel } from "popup/constants/blockaid"; + +const MAINNET = { + network: "PUBLIC", + networkPassphrase: "Public Global Stellar Network ; September 2015", +} as NetworkDetails; + +// minimal held balance shape: getAssetFromCanonical-compatible token entries +const heldAqua = { + token: { code: "AQUA", issuer: { key: "GBNZ" } }, + total: "100", +} as any; +const heldXlm = { token: { type: "native", code: "XLM" }, total: "50" } as any; + +describe("buildSwapSections — idle (no search term)", () => { + it("orders Your tokens then Popular (volume7d ∩ verified) and filters held out of Popular", () => { + const result = buildSwapSections({ + searchTerm: "", + balances: [heldAqua, heldXlm], + networkDetails: MAINNET, + popular: [ + { code: "AQUA", issuer: "GBNZ", domain: null, volume7d: 9 }, // held -> filtered from popular + { code: "USDC", issuer: "GUSD", domain: null, volume7d: 9 }, + ], + verifiedAssets: [{ code: "USDC", issuer: "GUSD", domain: null }], + unverifiedAssets: [], + }); + + expect(result.isSearch).toBe(false); + expect(result.sections.yourTokens.map((r) => r.code)).toEqual([ + "AQUA", + "XLM", + ]); + // AQUA dropped from Popular (held); USDC kept because it is in the verified set + expect(result.sections.popular.map((r) => r.code)).toEqual(["USDC"]); + expect(result.sections.popular[0].requiresTrustline).toBe(true); + expect(result.sections.popular[0].isHeld).toBe(false); + }); + + it("excludes Popular entries that are not in the verified set (volume7d ∩ verified)", () => { + const result = buildSwapSections({ + searchTerm: "", + balances: [], + networkDetails: MAINNET, + popular: [ + { code: "USDC", issuer: "GUSD", domain: null, volume7d: 9 }, + { code: "SCAM", issuer: "GSCAM", domain: null, volume7d: 9 }, + ], + verifiedAssets: [{ code: "USDC", issuer: "GUSD", domain: null }], + unverifiedAssets: [], + }); + expect(result.sections.popular.map((r) => r.code)).toEqual(["USDC"]); + }); +}); + +describe("buildSwapSections — search term", () => { + it("splits Your tokens / Verified / Unverified and dedupes by CODE:ISSUER", () => { + const result = buildSwapSections({ + searchTerm: "usd", + balances: [heldAqua], + networkDetails: MAINNET, + searchResults: [ + { code: "USDC", issuer: "GUSD", domain: null }, + { code: "USDC", issuer: "GUSD", domain: null }, // duplicate -> deduped + { code: "USDT", issuer: "GUSDT", domain: null }, + ], + verifiedAssets: [{ code: "USDC", issuer: "GUSD", domain: null }], + unverifiedAssets: [{ code: "USDT", issuer: "GUSDT", domain: null }], + }); + + expect(result.isSearch).toBe(true); + expect(result.sections.verified.map((r) => r.code)).toEqual(["USDC"]); + expect(result.sections.unverified.map((r) => r.code)).toEqual(["USDT"]); + }); + + it("drops Soroban contract results and sets hadSorobanMatches", () => { + const result = buildSwapSections({ + searchTerm: "CAZX", + balances: [], + networkDetails: MAINNET, + searchResults: [ + { + code: "WRAP", + issuer: "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7", + contract: "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7", + domain: null, + }, + ], + verifiedAssets: [], + unverifiedAssets: [], + }); + + expect(result.sections.verified).toEqual([]); + expect(result.sections.unverified).toEqual([]); + expect(result.hadSorobanMatches).toBe(true); + }); +}); + +describe("buildSwapSections — held-only fallback", () => { + it("on fallback, omits Popular and matches held tokens only", () => { + const result = buildSwapSections({ + searchTerm: "aqua", + balances: [heldAqua, heldXlm], + networkDetails: MAINNET, + isFallback: true, + }); + expect(result.isFallback).toBe(true); + expect(result.sections.popular).toEqual([]); + expect(result.sections.verified).toEqual([]); + expect(result.sections.unverified).toEqual([]); + expect(result.sections.yourTokens.map((r) => r.code)).toEqual(["AQUA"]); + }); +}); + +describe("mergeScanResults", () => { + it("stamps securityLevel from the bulk-scan map keyed by CODE-ISSUER", () => { + const rows = [ + { + code: "USDC", + issuer: "GUSD", + canonical: "USDC:GUSD", + isHeld: false, + requiresTrustline: true, + domain: null, + }, + ] as any; + const merged = mergeScanResults({ + rows, + scanResults: { "USDC-GUSD": { result_type: "Malicious" } } as any, + networkDetails: MAINNET, + }); + expect(merged[0].securityLevel).toBe(SecurityLevel.MALICIOUS); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts new file mode 100644 index 0000000000..729b749c9c --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -0,0 +1,547 @@ +import { useReducer, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { captureException } from "@sentry/browser"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { BlockAidScanAssetResult } from "@shared/api/types"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; +import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; +import { AssetType } from "@shared/api/types/account-balance"; + +import { initialState, reducer } from "helpers/request"; +import { RequestState } from "constants/request"; +import { isMainnet, getCanonicalFromAsset } from "helpers/stellar"; +import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; +import { SecurityLevel } from "popup/constants/blockaid"; +import { searchAsset } from "popup/helpers/searchAsset"; +import { splitVerifiedAssetCurrency } from "popup/helpers/assetList"; +import { isContractId, isAssetSac } from "popup/helpers/soroban"; +import { + scanAssetBulk, + isAssetMalicious, + isAssetSuspicious, + shouldTreatAssetAsUnableToScan, + isBlockaidEnabled, +} from "popup/helpers/blockaid"; +import { settingsSelector } from "popup/ducks/settings"; +import { + tokensListsSelector, + saveTokenLists, + popularTokensSelector, + savePopularTokens, + saveAssetScanResults, + POPULAR_TOKENS_STALE_MS, +} from "popup/ducks/cache"; +import { AppDispatch, store } from "popup/App"; +import { + fetchTrendingAssets, + TrendingAsset, +} from "popup/helpers/trendingAssets"; + +// Re-export RequestState for consumers +export { RequestState }; + +const MAX_ASSETS_TO_SCAN = 10; + +export interface SwapTokenRecord extends ManageAssetCurrency { + canonical: string; + isHeld: boolean; + isContract: boolean; + requiresTrustline: boolean; + securityLevel?: SecurityLevel; + fiatValue?: string; + percentChange24h?: string; +} + +export interface SwapTokenLookupResult { + sections: { + yourTokens: SwapTokenRecord[]; + popular: SwapTokenRecord[]; + verified: SwapTokenRecord[]; + unverified: SwapTokenRecord[]; + }; + isSearch: boolean; + hadSorobanMatches: boolean; + isFallback: boolean; +} + +export const EMPTY_RESULT: SwapTokenLookupResult = { + sections: { yourTokens: [], popular: [], verified: [], unverified: [] }, + isSearch: false, + hadSorobanMatches: false, + isFallback: false, +}; + +// ---- pure helpers (exported for unit tests) ---- + +/** + * Converts a held balance entry (AssetType) into a SwapTokenRecord. + * Returns null for LiquidityPoolShareAsset entries (no code/issuer). + */ +const heldToRecord = (balance: AssetType): SwapTokenRecord | null => { + if (!("token" in balance) || !balance.token) { + return null; + } + const token = balance.token as { + code: string; + type?: string; + issuer?: { key: string }; + }; + const isNative = + token.type === "native" || (token.code === "XLM" && !token.issuer); + const code = isNative ? "XLM" : token.code; + const issuer = isNative ? "" : token.issuer?.key || ""; + const canonical = isNative ? "native" : getCanonicalFromAsset(code, issuer); + return { + code, + issuer, + domain: null, + canonical, + isHeld: true, + isContract: false, + requiresTrustline: false, + }; +}; + +/** + * Converts a ManageAssetCurrency (search/popular result) into a SwapTokenRecord. + */ +const currencyToRecord = ( + asset: ManageAssetCurrency, + isHeld: boolean, +): SwapTokenRecord => { + const isNative = asset.code === "XLM" && !asset.issuer; + const canonical = isNative + ? "native" + : getCanonicalFromAsset(asset.code || "", asset.issuer || ""); + const isContract = !!(asset.contract && isContractId(asset.contract)); + return { + ...asset, + canonical, + isHeld, + isContract, + requiresTrustline: !isHeld && !isNative, + }; +}; + +/** + * Builds the section data for the swap destination picker. + * + * Idle (no searchTerm): yourTokens + popular (volume7d ∩ verified, held filtered out) + * Search (searchTerm): yourTokens + verified + unverified (mutually exclusive, deduped) + * Fallback: yourTokens only (held-in-memory filter), popular/verified/unverified empty + * + * Classic-only: any non-SAC Soroban contract is stripped and sets hadSorobanMatches = true. + */ +export const buildSwapSections = ({ + searchTerm, + balances, + networkDetails, + popular = [], + verifiedAssets = [], + unverifiedAssets = [], + searchResults = [], + isFallback = false, +}: { + searchTerm: string; + balances: AssetType[]; + networkDetails: NetworkDetails; + popular?: TrendingAsset[]; + verifiedAssets?: ManageAssetCurrency[]; + unverifiedAssets?: ManageAssetCurrency[]; + searchResults?: ManageAssetCurrency[]; + isFallback?: boolean; +}): SwapTokenLookupResult => { + const term = searchTerm.trim().toLowerCase(); + const isSearch = term.length > 0; + + const heldRecords = balances + .map(heldToRecord) + .filter((r): r is SwapTokenRecord => r !== null); + const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); + + // Classic-only filter: drop any Soroban (non-SAC) contract record. + // Side-effect: sets hadSorobanMatches when a Soroban record is encountered. + let hadSorobanMatches = false; + const isClassic = (asset: ManageAssetCurrency): boolean => { + // Check via explicit contract field + if (asset.contract && isContractId(asset.contract)) { + const sac = isAssetSac({ + asset: { + code: asset.code || "", + issuer: asset.issuer, + contract: asset.contract, + }, + networkDetails, + }); + if (!sac) { + hadSorobanMatches = true; + return false; + } + } + // Also catch assets whose issuer itself is a contract address (Soroban token) + if (asset.issuer && isContractId(asset.issuer)) { + hadSorobanMatches = true; + return false; + } + return true; + }; + + if (!isSearch) { + // IDLE mode + const verifiedKeys = new Set( + verifiedAssets.map((a) => + getCanonicalFromAsset(a.code || "", a.issuer || ""), + ), + ); + const popularRecords = popular + .map( + (p): ManageAssetCurrency => ({ + code: p.code, + issuer: p.issuer, + contract: p.contract, + domain: p.domain, + image: p.icon, + }), + ) + .filter(isClassic) + .map((a) => currencyToRecord(a, false)) + .filter( + (r) => + !heldCanonicals.has(r.canonical) && verifiedKeys.has(r.canonical), + ); + + return { + sections: { + yourTokens: heldRecords, + popular: isFallback ? [] : popularRecords, + verified: [], + unverified: [], + }, + isSearch: false, + hadSorobanMatches, + isFallback, + }; + } + + // SEARCH mode + const matchesTerm = (r: SwapTokenRecord): boolean => + (r.code || "").toLowerCase().includes(term) || + (r.issuer || "").toLowerCase().includes(term) || + (r.domain || "").toLowerCase().includes(term); + + const yourTokens = heldRecords.filter(matchesTerm); + + if (isFallback) { + return { + sections: { yourTokens, popular: [], verified: [], unverified: [] }, + isSearch: true, + hadSorobanMatches: false, + isFallback: true, + }; + } + + const heldSearchKeys = new Set(yourTokens.map((r) => r.canonical)); + + // Dedupe by canonical, exclude held (already in yourTokens) + const dedupe = (assets: ManageAssetCurrency[]): SwapTokenRecord[] => { + const seen = new Set(); + return assets + .filter(isClassic) + .map((a) => currencyToRecord(a, false)) + .filter((r) => { + if (heldSearchKeys.has(r.canonical) || seen.has(r.canonical)) { + return false; + } + seen.add(r.canonical); + return true; + }); + }; + + // Scan searchResults for Soroban entries to ensure hadSorobanMatches is set + // even when the split already stripped them from verifiedAssets/unverifiedAssets. + searchResults.forEach((a) => isClassic(a)); + + return { + sections: { + yourTokens, + popular: [], + verified: dedupe(verifiedAssets), + unverified: dedupe(unverifiedAssets), + }, + isSearch: true, + hadSorobanMatches, + isFallback: false, + }; +}; + +/** + * Stamps a securityLevel onto each SwapTokenRecord based on the bulk-scan result map. + * The map is keyed by "CODE-ISSUER" (matching how scanAssetBulk IDs are built). + * Native XLM (no issuer) is always trusted and left unmodified. + */ +export const mergeScanResults = ({ + rows, + scanResults, + networkDetails, +}: { + rows: SwapTokenRecord[]; + scanResults: Record; + networkDetails: NetworkDetails; +}): SwapTokenRecord[] => + rows.map((row) => { + if (!row.issuer) { + // Native XLM — always trusted + return row; + } + const scan = scanResults[`${row.code}-${row.issuer}`]; + let securityLevel: SecurityLevel; + if (shouldTreatAssetAsUnableToScan(scan, null, networkDetails)) { + securityLevel = SecurityLevel.UNABLE_TO_SCAN; + } else if (isAssetMalicious(scan)) { + securityLevel = SecurityLevel.MALICIOUS; + } else if (isAssetSuspicious(scan)) { + securityLevel = SecurityLevel.SUSPICIOUS; + } else { + securityLevel = SecurityLevel.SAFE; + } + return { ...row, securityLevel }; + }); + +// ---- helper to convert a Stellar Expert search record to ManageAssetCurrency ---- + +interface AssetSearchRecord { + asset: string; + domain?: string; + code?: string; + token_name?: string; + decimals?: number; + tomlInfo?: { + image?: string; + code?: string; + issuer?: string; + name?: string; + }; +} + +const recordFromSearchResult = ( + record: AssetSearchRecord, +): ManageAssetCurrency => { + if (isContractId(record.asset)) { + return { + code: record.code || record.tomlInfo?.code || "", + issuer: record.asset, + contract: record.asset, + domain: record.domain ?? null, + image: record.tomlInfo?.image, + }; + } + return { + code: record.asset.split("-")[0], + issuer: record.asset.split("-")[1], + domain: record.domain ?? null, + image: record.tomlInfo?.image, + }; +}; + +// ---- the hook ---- + +export const useSwapTokenLookup = () => { + const abortControllerRef = useRef(null); + const reduxDispatch = useDispatch(); + const { assetsLists } = useSelector(settingsSelector); + + const [state, dispatch] = useReducer( + reducer, + initialState, + ); + + const fetchData = async ({ + searchTerm, + balances, + publicKey: _publicKey, + networkDetails, + }: { + searchTerm: string; + balances: AssetType[]; + publicKey: string; + networkDetails: NetworkDetails; + }): Promise => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const { signal } = controller; + + dispatch({ type: "FETCH_DATA_START" }); + + // Token discovery (Popular + search) only exists on Mainnet / Testnet. + // Custom / Futurenet networks degrade to held-only (permanent fallback). + const supportsDiscovery = + isMainnet(networkDetails) || networkDetails.network === "TESTNET"; + + if (!supportsDiscovery) { + dispatch({ + type: "FETCH_DATA_SUCCESS", + payload: buildSwapSections({ + searchTerm, + balances, + networkDetails, + isFallback: true, + }), + }); + return; + } + + // Read the verified token-list cache from the Redux store (mirrors useAssetLookup). + let assetsListsData: AssetListResponse[] = tokensListsSelector( + store.getState(), + ); + if (!assetsListsData?.length) { + assetsListsData = await getCombinedAssetListData({ + networkDetails, + assetsLists, + cachedAssetLists: [], + }); + if (assetsListsData.length) { + reduxDispatch(saveTokenLists(assetsListsData)); + } + } + + try { + let verifiedAssets: ManageAssetCurrency[] = []; + let unverifiedAssets: ManageAssetCurrency[] = []; + let popular: TrendingAsset[] = []; + let searchResults: ManageAssetCurrency[] = []; + + if (searchTerm.trim()) { + // SEARCH path: fetch Stellar Expert results and split verified / unverified + const resJson = await searchAsset({ + asset: searchTerm.trim(), + networkDetails, + signal, + }); + searchResults = ( + (resJson?._embedded?.records as AssetSearchRecord[]) ?? [] + ).map(recordFromSearchResult); + + const split = await splitVerifiedAssetCurrency({ + networkDetails, + assets: searchResults, + assetsListsDetails: assetsLists, + cachedAssetLists: assetsListsData, + }); + verifiedAssets = split.verifiedAssets; + unverifiedAssets = split.unverifiedAssets; + } else { + // IDLE path: popular tokens (cached or fresh from stellar.expert) + const cachedByNetwork = popularTokensSelector(store.getState()); + const cached = cachedByNetwork[networkDetails.network]; + const isFresh = + cached && Date.now() - cached.updatedAt < POPULAR_TOKENS_STALE_MS; + + popular = isFresh + ? cached.tokens + : await fetchTrendingAssets({ networkDetails, signal }); + + if (!isFresh && popular.length) { + reduxDispatch(savePopularTokens({ networkDetails, tokens: popular })); + } + + // Intersect popular with verified lists to compute the verified canonical set. + // We only need the verified set here — the intersection happens inside + // buildSwapSections which checks verifiedKeys by canonical. + const popularAsCurrency: ManageAssetCurrency[] = popular.map((p) => ({ + code: p.code, + issuer: p.issuer, + domain: p.domain, + })); + const split = await splitVerifiedAssetCurrency({ + networkDetails, + assets: popularAsCurrency, + assetsListsDetails: assetsLists, + cachedAssetLists: assetsListsData, + }); + verifiedAssets = split.verifiedAssets; + } + + let payload = buildSwapSections({ + searchTerm, + balances, + networkDetails, + popular, + verifiedAssets, + unverifiedAssets, + searchResults, + }); + + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + + // Bulk Blockaid scan of non-held candidates (mainnet only; testnet = unable-to-scan) + if (isBlockaidEnabled(networkDetails)) { + const nonHeld = [ + ...payload.sections.popular, + ...payload.sections.verified, + ...payload.sections.unverified, + ].filter((r) => r.issuer && !isContractId(r.issuer)); + + if (nonHeld.length) { + const scanResults: Record = {}; + for (let i = 0; i < nonHeld.length; i += MAX_ASSETS_TO_SCAN) { + const chunk = nonHeld.slice(i, i + MAX_ASSETS_TO_SCAN); + const ids = chunk.map((r) => `${r.code}-${r.issuer}`); + const bulk = await scanAssetBulk(ids, networkDetails, signal); + if (bulk?.results) { + Object.assign(scanResults, bulk.results); + } + } + if (Object.keys(scanResults).length) { + reduxDispatch( + saveAssetScanResults({ networkDetails, results: scanResults }), + ); + payload = { + ...payload, + sections: { + yourTokens: payload.sections.yourTokens, + popular: mergeScanResults({ + rows: payload.sections.popular, + scanResults, + networkDetails, + }), + verified: mergeScanResults({ + rows: payload.sections.verified, + scanResults, + networkDetails, + }), + unverified: mergeScanResults({ + rows: payload.sections.unverified, + scanResults, + networkDetails, + }), + }, + }; + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + } + } + } + } catch (e) { + if (signal.aborted) { + // Cancelled — silently ignore (another call is already in flight) + return; + } + // Graceful fallback: stellar.expert or Blockaid unreachable → held-only + captureException(`useSwapTokenLookup fallback - ${JSON.stringify(e)}`); + dispatch({ + type: "FETCH_DATA_SUCCESS", + payload: buildSwapSections({ + searchTerm, + balances, + networkDetails, + isFallback: true, + }), + }); + } + }; + + return { fetchData, state }; +}; From e661d1aaeeb669568c2eea2e64d4271f6195e4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:06:27 -0300 Subject: [PATCH 014/121] [Extension] Add SwapTokenRow for swap picker (held + non-held variants) Co-Authored-By: Claude Opus 4.8 --- .../__tests__/SwapTokenRow.test.tsx | 114 +++++++++++++++ .../swap/SwapAsset/SwapTokenRow/index.tsx | 135 ++++++++++++++++++ .../swap/SwapAsset/SwapTokenRow/styles.scss | 71 +++++++++ .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + 5 files changed, 322 insertions(+) create mode 100644 extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx create mode 100644 extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx create mode 100644 extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx new file mode 100644 index 0000000000..f6405be4db --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { SecurityLevel } from "popup/constants/blockaid"; +import { SwapTokenRow } from "../index"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock("popup/components/account/AccountAssets", () => ({ + AssetIcon: () =>
, +})); + +const mockOpenTab = jest.fn(); +jest.mock("popup/helpers/navigate", () => ({ + openTab: (...args: unknown[]) => mockOpenTab(...args), +})); + +const AQUA_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +const baseProps = { + code: "AQUA", + issuerKey: AQUA_ISSUER, + domain: "aqua.network", + iconUrl: "", + onClick: jest.fn(), + stellarExpertUrl: "https://stellar.expert/explorer/public", +}; + +describe("SwapTokenRow", () => { + afterEach(() => jest.clearAllMocks()); + + it("held row shows fiat value and 24h change, no context menu", () => { + render( + , + ); + + expect(screen.getByTestId("SwapTokenRow-AQUA-fiat")).toHaveTextContent( + "$1,234.56", + ); + expect(screen.getByTestId("SwapTokenRow-AQUA-change")).toHaveTextContent( + "+1.23%", + ); + expect(screen.queryByTestId("SwapTokenRow-AQUA-menu")).toBeNull(); + }); + + it("non-held row shows context menu and no fiat value", () => { + render(); + + expect(screen.queryByTestId("SwapTokenRow-AQUA-fiat")).toBeNull(); + expect(screen.getByTestId("SwapTokenRow-AQUA-menu")).toBeInTheDocument(); + }); + + it("renders the ScamAssetIcon badge when malicious in non-held variant", () => { + render( + , + ); + + expect(screen.getByTestId("ScamAssetIcon")).toBeInTheDocument(); + }); + + it("does not render the ScamAssetIcon badge when held, even if malicious", () => { + render( + , + ); + + expect(screen.queryByTestId("ScamAssetIcon")).toBeNull(); + }); + + it("does not render the badge when safe", () => { + render( + , + ); + + expect(screen.queryByTestId("ScamAssetIcon")).toBeNull(); + }); + + it("calls onClick when the row body is clicked", () => { + const onClick = jest.fn(); + render(); + + fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-body")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("View on stellar.expert opens the asset page in a new tab", () => { + render(); + + fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-menu")); + fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-view-expert")); + + expect(mockOpenTab).toHaveBeenCalledWith( + `https://stellar.expert/explorer/public/asset/AQUA-${AQUA_ISSUER}`, + ); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx new file mode 100644 index 0000000000..58e2b2ee44 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import { Icon } from "@stellar/design-system"; + +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { ScamAssetIcon } from "popup/components/account/ScamAssetIcon"; +import { SecurityLevel } from "popup/constants/blockaid"; +import { openTab } from "popup/helpers/navigate"; +import { formatDomain } from "helpers/stellar"; + +import "./styles.scss"; + +export interface SwapTokenRowProps { + code: string; + issuerKey?: string; + domain?: string | null; + iconUrl?: string; + isHeld: boolean; + fiatValue?: string; + percentChange24h?: string; + securityLevel?: SecurityLevel; + onClick: () => void; + stellarExpertUrl: string; +} + +export const SwapTokenRow = ({ + code, + issuerKey = "", + domain, + iconUrl = "", + isHeld, + fiatValue, + percentChange24h, + securityLevel, + onClick, + stellarExpertUrl, +}: SwapTokenRowProps) => { + const { t } = useTranslation(); + const canonical = issuerKey ? `${code}:${issuerKey}` : "native"; + const isScamAsset = + securityLevel === SecurityLevel.MALICIOUS || + securityLevel === SecurityLevel.SUSPICIOUS; + + const copyAddress = async () => { + if (!issuerKey) return; + await navigator.clipboard.writeText(issuerKey); + }; + + const viewOnExpert = () => { + openTab(`${stellarExpertUrl}/asset/${code}-${issuerKey}`); + }; + + return ( +
+
+
+ + {!isHeld && } +
+
+
{code}
+ {domain ? ( +
+ {formatDomain(domain)} +
+ ) : null} +
+
+ + {isHeld ? ( +
+
+ {fiatValue || "--"} +
+ {percentChange24h ? ( +
+ {percentChange24h} +
+ ) : null} +
+ ) : ( + + + + + + + + + + + + )} +
+ ); +}; diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss new file mode 100644 index 0000000000..200b1da7fc --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss @@ -0,0 +1,71 @@ +.SwapTokenRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; + cursor: pointer; + + &__body { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; + } + + &__icon { + position: relative; + } + + &__title { + display: flex; + flex-direction: column; + min-width: 0; + + &__code { + font-weight: var(--sds-fw-semi-bold, 600); + } + + &__domain { + color: var(--sds-clr-gray-09); + font-size: 0.75rem; + } + } + + &__value { + text-align: right; + + &__change { + color: var(--sds-clr-gray-09); + font-size: 0.75rem; + } + } + + &__menu { + background: transparent; + border: none; + cursor: pointer; + color: var(--sds-clr-gray-11); + padding: 0.25rem; + + &__content { + display: flex; + flex-direction: column; + background: var(--sds-clr-gray-03); + border: 1px solid var(--sds-clr-gray-06); + border-radius: 0.5rem; + padding: 0.25rem; + z-index: 10; + } + + &__item { + background: transparent; + border: none; + text-align: left; + padding: 0.5rem 0.75rem; + cursor: pointer; + color: var(--sds-clr-gray-12); + white-space: nowrap; + } + } +} diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 0ddd30cdd0..444246f412 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -382,6 +382,7 @@ "Min Price": "Min Price", "Minimum XLM needed": "Minimum XLM needed", "Minted": "Minted", + "More options": "More options", "Multiple assets": "Multiple assets", "Multiple assets have a similar code, please check the domain before adding.": "Multiple assets have a similar code, please check the domain before adding.", "must be at least": "must be at least", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index a0cb327877..7663e48bf7 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -382,6 +382,7 @@ "Min Price": "Preço Mínimo", "Minimum XLM needed": "XLM mínimo necessário", "Minted": "Cunhado", + "More options": "Mais opções", "Multiple assets": "Múltiplos ativos", "Multiple assets have a similar code, please check the domain before adding.": "Vários ativos têm um código similar, verifique o domínio antes de adicionar.", "must be at least": "deve ser pelo menos", From c0761585476781c63e00c73dc23ea102ea73d668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:14:52 -0300 Subject: [PATCH 015/121] [Extension] Add Verified/Unverified token info sheets for swap picker 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 --- .../InfoSheets/__tests__/InfoSheets.test.tsx | 44 +++++++++++++ .../swap/SwapAsset/InfoSheets/index.tsx | 61 +++++++++++++++++++ .../src/popup/locales/en/translation.json | 3 + .../src/popup/locales/pt/translation.json | 3 + 4 files changed, 111 insertions(+) create mode 100644 extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx create mode 100644 extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx diff --git a/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx b/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx new file mode 100644 index 0000000000..c72a1644fb --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { VerifiedTokenInfoSheet, UnverifiedTokenInfoSheet } from "../index"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("Token info sheets", () => { + it("VerifiedTokenInfoSheet renders its copy when open", () => { + render(); + expect( + screen.getByText( + "Freighter uses asset lists to verify assets before interactions.", + ), + ).toBeInTheDocument(); + }); + + it("UnverifiedTokenInfoSheet renders its caution copy when open", () => { + render(); + expect( + screen.getByText( + "These tokens are not on any of your lists. Proceed with caution.", + ), + ).toBeInTheDocument(); + }); + + it("VerifiedTokenInfoSheet calls onClose when dismiss button is clicked", () => { + const mockOnClose = jest.fn(); + render(); + const dismissButton = screen.getByText("Got it"); + fireEvent.click(dismissButton); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("UnverifiedTokenInfoSheet calls onClose when dismiss button is clicked", () => { + const mockOnClose = jest.fn(); + render(); + const dismissButton = screen.getByText("Got it"); + fireEvent.click(dismissButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx b/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx new file mode 100644 index 0000000000..00a5ee7e1c --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@stellar/design-system"; + +import { SlideupModal } from "popup/components/SlideupModal"; + +interface InfoSheetProps { + isOpen: boolean; + onClose: () => void; +} + +export const VerifiedTokenInfoSheet = ({ isOpen, onClose }: InfoSheetProps) => { + const { t } = useTranslation(); + return ( + onClose()}> +
+
{t("Verified tokens")}
+

+ {t( + "Freighter uses asset lists to verify assets before interactions.", + )} +

+ +
+
+ ); +}; + +export const UnverifiedTokenInfoSheet = ({ + isOpen, + onClose, +}: InfoSheetProps) => { + const { t } = useTranslation(); + return ( + onClose()}> +
+
{t("Unverified tokens")}
+

+ {t( + "These tokens are not on any of your lists. Proceed with caution.", + )} +

+ +
+
+ ); +}; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 444246f412..44d130d2d1 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -603,6 +603,7 @@ "There was an error fetching protocols. Please refresh and try again.": "There was an error fetching protocols. Please refresh and try again.", "These assets are not on any of your lists. Proceed with caution before adding.": "These assets are not on any of your lists. Proceed with caution before adding.", "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.": "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.", + "These tokens are not on any of your lists. Proceed with caution.": "These tokens are not on any of your lists. Proceed with caution.", "These words are your wallet’s keys—store them securely to keep your funds safe.": "These words are your wallet’s keys—store them securely to keep your funds safe.", "This asset does not appear safe for the following reasons.": "This asset does not appear safe for the following reasons.", "This asset has a balance": "This asset has a balance", @@ -686,6 +687,7 @@ "Unknown error occured": "Unknown error occured", "Unlock": "Unlock", "Unsupported signing method": "Unsupported signing method", + "Unverified tokens": "Unverified tokens", "Upload Contract Wasm": "Upload Contract Wasm", "Use caution when connecting to domains without an SSL certificate.": "Use caution when connecting to domains without an SSL certificate.", "Use default account": "Use default account", @@ -697,6 +699,7 @@ "Validate addresses that require a memo": "Validate addresses that require a memo", "Value": "Value", "Verification with": "Verification with", + "Verified tokens": "Verified tokens", "Version": "Version", "View": "View", "View maintenance details": "View maintenance details", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 7663e48bf7..997918105b 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -603,6 +603,7 @@ "There was an error fetching protocols. Please refresh and try again.": "Ocorreu um erro ao buscar os protocolos. Atualize e tente novamente.", "These assets are not on any of your lists. Proceed with caution before adding.": "Esses ativos não estão em nenhuma de suas listas. Prossiga com cautela antes de adicionar.", "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.": "Estes serviços são operados por terceiros independentes, não pela Freighter ou SDF. A inclusão aqui não constitui um endosso. DeFi envolve riscos, incluindo perda de fundos. Use por sua conta e risco.", + "These tokens are not on any of your lists. Proceed with caution.": "These tokens are not on any of your lists. Proceed with caution.", "These words are your wallet’s keys—store them securely to keep your funds safe.": "Essas palavras são as chaves da sua carteira—guarde-as com segurança para manter seus fundos seguros.", "This asset does not appear safe for the following reasons.": "Este ativo não parece seguro pelos seguintes motivos.", "This asset has a balance": "Este ativo tem um saldo", @@ -686,6 +687,7 @@ "Unknown error occured": "Erro desconhecido ocorreu", "Unlock": "Desbloquear", "Unsupported signing method": "Método de assinatura não suportado", + "Unverified tokens": "Unverified tokens", "Upload Contract Wasm": "Carregar Wasm do Contrato", "Use caution when connecting to domains without an SSL certificate.": "Use cautela ao se conectar a domínios sem um certificado SSL.", "Use default account": "Usar conta padrão", @@ -697,6 +699,7 @@ "Validate addresses that require a memo": "Validar endereços que requerem memo", "Value": "Valor", "Verification with": "Verificação com", + "Verified tokens": "Verified tokens", "Version": "Versão", "View": "Ver", "View maintenance details": "Ver detalhes da manutenção", From 4002e0288bd2104a124d0c962fba97cf78662a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:22:58 -0300 Subject: [PATCH 016/121] [Extension] Add SwapPickerSections (sections, info icons, empty/fallback states) Co-Authored-By: Claude Opus 4.8 --- .../__tests__/SwapPickerSections.test.tsx | 171 ++++++++++++++++ .../SwapAsset/SwapPickerSections/index.tsx | 186 ++++++++++++++++++ .../SwapAsset/SwapPickerSections/styles.scss | 35 ++++ .../src/popup/locales/en/translation.json | 9 + .../src/popup/locales/pt/translation.json | 9 + 5 files changed, 410 insertions(+) create mode 100644 extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx create mode 100644 extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx create mode 100644 extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx new file mode 100644 index 0000000000..7a02cc064e --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { SwapPickerSections } from "../index"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { term?: string }) => + opts?.term ? key.replace("{{term}}", opts.term) : key, + }), +})); + +// SwapTokenRow is unit-tested separately; stub it to a simple marker so these +// tests assert section structure, not row internals. +jest.mock("../../SwapTokenRow", () => ({ + SwapTokenRow: ({ code }: { code: string }) => ( +
+ ), +})); + +const rec = (code: string, isHeld = false) => ({ + canonical: `${code}:G123`, + code, + issuer: "G123", + domain: "example.org", + image: "", + isHeld, + isContract: false, + requiresTrustline: false, +}); + +const emptyResult = { + yourTokens: [], + popular: [], + verified: [], + unverified: [], + hadSorobanMatches: false, + isFallback: false, + isNewAccount: false, +}; + +const baseProps = { + onClickAsset: jest.fn(), + stellarExpertUrl: "https://stellar.expert/explorer/public", +}; + +describe("SwapPickerSections", () => { + it("idle: renders Your tokens then Popular sections in order", () => { + render( + , + ); + + const headers = screen.getAllByTestId(/^swap-section-/); + expect(headers[0]).toHaveAttribute( + "data-testid", + "swap-section-your-tokens", + ); + expect(headers[1]).toHaveAttribute("data-testid", "swap-section-popular"); + expect(screen.getByTestId("row-USDC")).toBeInTheDocument(); + expect(screen.getByTestId("row-AQUA")).toBeInTheDocument(); + }); + + it("new account: renders Popular only (no Your tokens header)", () => { + render( + , + ); + + expect(screen.queryByTestId("swap-section-your-tokens")).toBeNull(); + expect(screen.getByTestId("swap-section-popular")).toBeInTheDocument(); + }); + + it("search active: renders Verified + Unverified with (i) info icons", () => { + render( + , + ); + + expect( + screen.getByTestId("swap-section-verified-info"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("swap-section-unverified-info"), + ).toBeInTheDocument(); + + // tapping (i) opens the verified info sheet + fireEvent.click(screen.getByTestId("swap-section-verified-info")); + expect(screen.getByTestId("verified-token-info-sheet")).toBeInTheDocument(); + }); + + it("generic empty state shows the search term", () => { + render( + , + ); + + expect(screen.getByTestId("swap-picker-empty")).toHaveTextContent( + "No tokens match zzz", + ); + }); + + it("Soroban empty state shown when hadSorobanMatches", () => { + render( + , + ); + + expect(screen.getByTestId("swap-picker-empty-soroban")).toBeInTheDocument(); + }); + + it("soft fallback notice rendered when isFallback", () => { + render( + , + ); + + expect( + screen.getByTestId("swap-picker-fallback-notice"), + ).toBeInTheDocument(); + }); + + it("idle + new account + no popular: renders nothing (no generic empty state)", () => { + render( + , + ); + + // Generic empty state with "No tokens match" should not render + expect(screen.queryByTestId("swap-picker-empty")).toBeNull(); + // No sections should render + expect(screen.queryByTestId(/^swap-section-/)).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx new file mode 100644 index 0000000000..ed1b912158 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx @@ -0,0 +1,186 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon, Notification } from "@stellar/design-system"; + +import { SwapTokenRow } from "../SwapTokenRow"; +import { + VerifiedTokenInfoSheet, + UnverifiedTokenInfoSheet, +} from "../InfoSheets"; +import type { SwapTokenRecord } from "../hooks/useSwapTokenLookup"; + +import "./styles.scss"; + +/** + * Flat sections shape accepted by this presentational component. + * Callers consuming `useSwapTokenLookup` should destructure `state.data.sections` + * and merge it with the top-level flags before passing here. + */ +export interface SwapPickerSectionsResult { + yourTokens: SwapTokenRecord[]; + popular: SwapTokenRecord[]; + verified: SwapTokenRecord[]; + unverified: SwapTokenRecord[]; + hadSorobanMatches: boolean; + isFallback: boolean; + /** True when the account has no held balances (new/unfunded account). */ + isNewAccount: boolean; +} + +export interface SwapPickerSectionsProps { + result: SwapPickerSectionsResult; + searchTerm: string; + onClickAsset: (canonical: string, isContract: boolean) => void; + stellarExpertUrl: string; +} + +export const SwapPickerSections = ({ + result, + searchTerm, + onClickAsset, + stellarExpertUrl, +}: SwapPickerSectionsProps) => { + const { t } = useTranslation(); + const [verifiedSheetOpen, setVerifiedSheetOpen] = useState(false); + const [unverifiedSheetOpen, setUnverifiedSheetOpen] = useState(false); + + const isSearching = searchTerm.trim().length > 0; + + const renderRows = (records: SwapTokenRecord[]) => + records.map((r) => ( + onClickAsset(r.canonical, r.isContract)} + /> + )); + + const hasResults = isSearching + ? result.yourTokens.length + + result.verified.length + + result.unverified.length > + 0 + : (result.isNewAccount ? 0 : result.yourTokens.length) + + result.popular.length > + 0; + + return ( +
+ {result.isFallback && ( +
+ +
+ )} + + {!hasResults ? ( + result.hadSorobanMatches ? ( +
+ {t( + "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", + )} +
+ ) : isSearching ? ( +
+ {t("No tokens match {{term}}", { term: searchTerm })} +
+ ) : null + ) : ( + <> + {!result.isNewAccount && result.yourTokens.length > 0 && ( + <> +
+ {t("Your tokens")} +
+ {renderRows(result.yourTokens)} + + )} + + {!isSearching && result.popular.length > 0 && ( + <> +
+ {t("Popular tokens")} +
+ {renderRows(result.popular)} + + )} + + {isSearching && result.verified.length > 0 && ( + <> +
+ {t("Verified")} + +
+ {renderRows(result.verified)} + + )} + + {isSearching && result.unverified.length > 0 && ( + <> +
+ + {t("Unverified")} + + +
+ {renderRows(result.unverified)} + + )} + + )} + + setVerifiedSheetOpen(false)} + /> + setUnverifiedSheetOpen(false)} + /> +
+ ); +}; diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss new file mode 100644 index 0000000000..5c75aeeb92 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss @@ -0,0 +1,35 @@ +.SwapPickerSections { + display: flex; + flex-direction: column; + + &__notice { + margin-bottom: 0.75rem; + } + + &__header { + display: flex; + align-items: center; + gap: 0.375rem; + margin-top: 1rem; + margin-bottom: 0.25rem; + color: var(--sds-clr-gray-11); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + + &__info { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + color: var(--sds-clr-gray-09); + display: inline-flex; + } + } + + &__empty { + padding: 2rem 1rem; + text-align: center; + color: var(--sds-clr-gray-09); + } +} diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 44d130d2d1..1641088251 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -5,6 +5,8 @@ "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "A new signing request arrived while you were reviewing another. Please review it carefully before approving.", "About": "About", + "About unverified tokens": "About unverified tokens", + "About verified tokens": "About verified tokens", "Account": "Account", "Account details": "Account details", "Account ID": "Account ID", @@ -410,6 +412,7 @@ "No device detected.": "No device detected.", "No hidden collectibles": "No hidden collectibles", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", + "No tokens match {{term}}": "No tokens match {{term}}", "No transactions to show": "No transactions to show", "None": "None", "Not enough lumens": "Not enough lumens", @@ -458,6 +461,7 @@ "Please try again with a different value.": "Please try again with a different value.", "Please try again.": "Please try again.", "Please try using the suggested fee and try again.": "Please try using the suggested fee and try again.", + "Popular tokens": "Popular tokens", "powered by": "powered by", "Powered by ": "Powered by ", "Pre Auth Transaction": "Pre Auth Transaction", @@ -549,6 +553,7 @@ "Some destination accounts on the Stellar network require a memo to identify your payment.": "Some destination accounts on the Stellar network require a memo to identify your payment.", "Some features may be disabled at this time": "Some features may be disabled at this time.", "Some of your assets may not appear, but they are still safe on the network!": "Some of your assets may not appear, but they are still safe on the network!", + "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.": "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", "Soroban is temporarily experiencing issues": "Soroban is temporarily experiencing issues", "Soroban RPC is temporarily experiencing issues": "Soroban RPC is temporarily experiencing issues", "SOROBAN RPC URL": "SOROBAN RPC URL", @@ -644,6 +649,7 @@ "To create a new account you need to send at least 1 XLM to it.": "To create a new account you need to send at least 1 XLM to it.", "To start using this account, fund it with at least 1 XLM.": "To start using this account, fund it with at least 1 XLM.", "Toggle Assets": "Toggle Assets", + "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", "Token ID": "Token ID", "Token ID cannot contain spaces": "Token ID cannot contain spaces", "Token ID is required": "Token ID is required", @@ -687,6 +693,7 @@ "Unknown error occured": "Unknown error occured", "Unlock": "Unlock", "Unsupported signing method": "Unsupported signing method", + "Unverified": "Unverified", "Unverified tokens": "Unverified tokens", "Upload Contract Wasm": "Upload Contract Wasm", "Use caution when connecting to domains without an SSL certificate.": "Use caution when connecting to domains without an SSL certificate.", @@ -699,6 +706,7 @@ "Validate addresses that require a memo": "Validate addresses that require a memo", "Value": "Value", "Verification with": "Verification with", + "Verified": "Verified", "Verified tokens": "Verified tokens", "Version": "Version", "View": "View", @@ -773,6 +781,7 @@ "Your send data could not be fetched at this time.": "Your send data could not be fetched at this time.", "Your Stellar secret key": "Your Stellar secret key", "Your swap data could not be fetched at this time.": "Your swap data could not be fetched at this time.", + "Your tokens": "Your tokens", "Your Tokens": "Your Tokens", "Your wallets could not be fetched at this time.": "Your wallets could not be fetched at this time." } diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 997918105b..f0cfb778fa 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -5,6 +5,8 @@ "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "Uma conta de destino requer o uso do campo memo que não está presente na transação que você está prestes a assinar.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "Uma nova solicitação de assinatura chegou enquanto você revisava outra. Revise-a com atenção antes de aprovar.", "About": "Sobre", + "About unverified tokens": "About unverified tokens", + "About verified tokens": "About verified tokens", "Account": "Conta", "Account details": "Detalhes da conta", "Account ID": "ID da Conta", @@ -410,6 +412,7 @@ "No device detected.": "Nenhum dispositivo detectado.", "No hidden collectibles": "Nenhum colecionável oculto", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", + "No tokens match {{term}}": "No tokens match {{term}}", "No transactions to show": "Nenhuma transação para mostrar", "None": "Nenhum", "Not enough lumens": "Lumens insuficientes", @@ -458,6 +461,7 @@ "Please try again with a different value.": "Por favor, tente novamente com um valor diferente.", "Please try again.": "Por favor, tente novamente.", "Please try using the suggested fee and try again.": "Por favor, tente usar a taxa sugerida e tente novamente.", + "Popular tokens": "Popular tokens", "powered by": "desenvolvido por", "Powered by ": "Desenvolvido por ", "Pre Auth Transaction": "Transação Pré-Autorizada", @@ -549,6 +553,7 @@ "Some destination accounts on the Stellar network require a memo to identify your payment.": "Algumas contas de destino na rede Stellar exigem um memo para identificar seu pagamento.", "Some features may be disabled at this time": "Alguns recursos podem estar desabilitados neste momento.", "Some of your assets may not appear, but they are still safe on the network!": "Alguns de seus ativos podem não aparecer, mas ainda estão seguros na rede!", + "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.": "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", "Soroban is temporarily experiencing issues": "O Soroban está temporariamente com problemas", "Soroban RPC is temporarily experiencing issues": "O Soroban RPC está temporariamente com problemas", "SOROBAN RPC URL": "URL RPC SOROBAN", @@ -644,6 +649,7 @@ "To create a new account you need to send at least 1 XLM to it.": "Para criar uma nova conta, você precisa enviar pelo menos 1 XLM para ela.", "To start using this account, fund it with at least 1 XLM.": "Para começar a usar esta conta, financie-a com pelo menos 1 XLM.", "Toggle Assets": "Alternar Ativos", + "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", "Token ID": "ID do Token", "Token ID cannot contain spaces": "ID do token não pode conter espaços", "Token ID is required": "ID do token é obrigatório", @@ -687,6 +693,7 @@ "Unknown error occured": "Erro desconhecido ocorreu", "Unlock": "Desbloquear", "Unsupported signing method": "Método de assinatura não suportado", + "Unverified": "Unverified", "Unverified tokens": "Unverified tokens", "Upload Contract Wasm": "Carregar Wasm do Contrato", "Use caution when connecting to domains without an SSL certificate.": "Use cautela ao se conectar a domínios sem um certificado SSL.", @@ -699,6 +706,7 @@ "Validate addresses that require a memo": "Validar endereços que requerem memo", "Value": "Valor", "Verification with": "Verificação com", + "Verified": "Verified", "Verified tokens": "Verified tokens", "Version": "Versão", "View": "Ver", @@ -771,6 +779,7 @@ "Your send data could not be fetched at this time.": "Seus dados de envio não puderam ser buscados neste momento.", "Your Stellar secret key": "Sua Stellar secret key", "Your swap data could not be fetched at this time.": "Seus dados de troca não puderam ser buscados neste momento.", + "Your tokens": "Your tokens", "Your Tokens": "Seus Tokens", "Your wallets could not be fetched at this time.": "Suas carteiras não puderam ser buscadas neste momento." } From 81268ecc52479b865cb25a442c4facc3ca1af43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:32:27 -0300 Subject: [PATCH 017/121] [Extension] Parameterize SwapAsset with selectionType (source/destination) Co-Authored-By: Claude Opus 4.8 --- .../SwapAsset/__tests__/SwapAsset.test.tsx | 89 +++++++++ .../popup/components/swap/SwapAsset/index.tsx | 169 ++++++++++++++---- extension/src/popup/views/Swap/index.tsx | 7 +- 3 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx new file mode 100644 index 0000000000..e45daa791c --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { Wrapper } from "popup/__testHelpers__"; +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import * as UseSwapFromData from "popup/components/swap/SwapAsset/hooks/useSwapFromData"; +import * as UseSwapTokenLookup from "popup/components/swap/SwapAsset/hooks/useSwapTokenLookup"; +import { SwapAsset } from "popup/components/swap/SwapAsset"; + +const resolvedFromState = { + state: RequestState.SUCCESS, + data: { + type: AppDataType.RESOLVED, + publicKey: "G123", + balances: { balances: [], icons: {} }, + filteredBalances: [], + networkDetails: { network: "PUBLIC", networkUrl: "" }, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + tokenPrices: {}, + }, + error: null, +}; + +const emptyLookupResult = { + sections: { + yourTokens: [], + popular: [], + verified: [], + unverified: [], + }, + isSearch: false, + hadSorobanMatches: false, + isFallback: false, +}; + +describe("SwapAsset selectionType", () => { + beforeEach(() => { + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: resolvedFromState, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: emptyLookupResult, + error: null, + }, + } as any); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("source: renders the 'Swap from' header and the held TokenList", () => { + render( + + + , + ); + + expect(screen.getByText("Swap from")).toBeInTheDocument(); + expect(screen.getByTestId("token-list")).toBeInTheDocument(); + expect(screen.queryByTestId("swap-picker-sections")).toBeNull(); + }); + + it("destination: renders the 'Swap to' header and SwapPickerSections", () => { + render( + + + , + ); + + expect(screen.getByText("Swap to")).toBeInTheDocument(); + expect(screen.getByTestId("swap-picker-sections")).toBeInTheDocument(); + expect(screen.queryByTestId("token-list")).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index 36d2f9a9a3..594303daa1 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -4,6 +4,7 @@ import { Icon, Input, Loader } from "@stellar/design-system"; import { useFormik } from "formik"; import { debounce } from "lodash"; import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; import { TokenList } from "popup/components/InternalTransaction/TokenList"; import { SubviewHeader } from "popup/components/SubviewHeader"; @@ -14,35 +15,71 @@ import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; +import { getStellarExpertUrl } from "popup/helpers/account"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { useGetSwapFromData } from "./hooks/useSwapFromData"; +import { useSwapTokenLookup } from "./hooks/useSwapTokenLookup"; +import { SwapPickerSections } from "./SwapPickerSections"; +import type { SwapPickerSectionsResult } from "./SwapPickerSections"; import "./styles.scss"; interface SwapAssetProps { - title: string; + selectionType: "source" | "destination"; hiddenAssets: string[]; onClickAsset: (canonical: string, isContract: boolean) => void; goBack: () => void; } export const SwapAsset = ({ - title, + selectionType, hiddenAssets, onClickAsset, goBack, }: SwapAssetProps) => { const { t } = useTranslation(); - const { state, fetchData, filterBalances } = useGetSwapFromData({ - showHidden: false, - includeIcons: true, - }); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const isDestination = selectionType === "destination"; + const title = isDestination ? t("Swap to") : t("Swap from"); + + const { + state: fromState, + fetchData, + filterBalances, + } = useGetSwapFromData({ showHidden: false, includeIcons: true }); + + const { fetchData: lookupFetchData, state: lookupState } = + useSwapTokenLookup(); - const isLoading = - state.state === RequestState.IDLE || state.state === RequestState.LOADING; + const isLoading = isDestination + ? lookupState.state === RequestState.IDLE || + lookupState.state === RequestState.LOADING + : fromState.state === RequestState.IDLE || + fromState.state === RequestState.LOADING; const formik = useFormik({ initialValues: { searchTerm: "" }, - onSubmit: (values) => filterBalances(values.searchTerm), + onSubmit: (values) => { + if (isDestination) { + const resolvedFrom = fromState.data; + const balances = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.balances.balances + : []; + const publicKey = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.publicKey + : ""; + lookupFetchData({ + searchTerm: values.searchTerm, + balances, + publicKey, + networkDetails, + }); + } else { + filterBalances(values.searchTerm); + } + }, validateOnChange: false, }); @@ -51,7 +88,8 @@ export const SwapAsset = ({ debounce(() => { formik.submitForm(); }, 300), - [formik], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], ); const handleChange = (e: React.ChangeEvent) => { @@ -59,40 +97,92 @@ export const SwapAsset = ({ formik.setFieldValue("searchTerm", val); debouncedSubmit(); }; + useEffect(() => { - const getData = async () => { - await fetchData(true); - }; - getData(); + if (!isDestination) { + const getData = async () => { + await fetchData(true); + }; + getData(); + } else { + // Trigger initial idle fetch (populate held tokens + popular) + const resolvedFrom = fromState.data; + const balances = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.balances.balances + : []; + const publicKey = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.publicKey + : ""; + lookupFetchData({ + searchTerm: "", + balances, + publicKey, + networkDetails, + }); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const hasError = state.state === RequestState.ERROR; - if (state.data?.type === AppDataType.REROUTE) { - if (state.data.shouldOpenTab) { - openTab(newTabHref(state.data.routeTarget)); - window.close(); + // Source-only rerouting/onboarding guard + if (!isDestination) { + if (fromState.data?.type === AppDataType.REROUTE) { + if (fromState.data.shouldOpenTab) { + openTab(newTabHref(fromState.data.routeTarget)); + window.close(); + } + return ( + + ); } - return ( - - ); - } - if (!hasError && !isLoading) { - reRouteOnboarding({ - type: state.data.type, - applicationState: state.data?.applicationState, - state: state.state, - }); + const hasError = fromState.state === RequestState.ERROR; + // At this point fromState.data is either null/undefined or ResolvedSwapFrom + // (REROUTE was handled above). Only call reRouteOnboarding when resolved. + if ( + !hasError && + !isLoading && + fromState.data?.type === AppDataType.RESOLVED + ) { + reRouteOnboarding({ + type: fromState.data.type, + applicationState: fromState.data.applicationState, + state: fromState.state, + }); + } } - const icons = state.data?.balances.icons || {}; - const tokenPrices = state.data?.tokenPrices || {}; - const balances = state.data?.filteredBalances || []; + const resolvedFromData = + fromState.data?.type === AppDataType.RESOLVED ? fromState.data : null; + const icons = resolvedFromData?.balances?.icons || {}; + const tokenPrices = resolvedFromData?.tokenPrices || {}; + const balances = resolvedFromData?.filteredBalances || []; + const stellarExpertUrl = getStellarExpertUrl(networkDetails); + + // Build the flat sections result for SwapPickerSections + const lookupData = lookupState.data; + const heldBalancesForNewAccount = resolvedFromData?.balances.balances || []; + const pickerResult: SwapPickerSectionsResult = lookupData + ? { + ...lookupData.sections, + hadSorobanMatches: lookupData.hadSorobanMatches, + isFallback: lookupData.isFallback, + isNewAccount: heldBalancesForNewAccount.length === 0, + } + : { + yourTokens: [], + popular: [], + verified: [], + unverified: [], + hadSorobanMatches: false, + isFallback: false, + isNewAccount: true, + }; return ( <> @@ -121,6 +211,13 @@ export const SwapAsset = ({
+ ) : isDestination ? ( + ) : ( > = { }; export const Swap = () => { - const { t } = useTranslation(); const dispatch = useDispatch(); const navigate = useNavigate(); const location = useLocation(); @@ -106,7 +103,7 @@ export const Swap = () => { case STEPS.SET_DST_ASSET: { return ( setActiveStep(STEPS.AMOUNT)} onClickAsset={(canonical: string, isContract: boolean) => { @@ -149,7 +146,7 @@ export const Swap = () => { default: { return ( setActiveStep(STEPS.AMOUNT)} onClickAsset={(canonical: string, isContract: boolean) => { From 775ecccd0b433a519bd6b9b3974a409f544c8fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:42:03 -0300 Subject: [PATCH 018/121] Extract buildChangeTrustOperation from getManageAssetXDR 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 --- .../__tests__/getManageAssetXDR.test.ts | 38 +++++++++++++++++++ .../src/popup/helpers/getManageAssetXDR.ts | 29 ++++++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 extension/src/popup/helpers/__tests__/getManageAssetXDR.test.ts diff --git a/extension/src/popup/helpers/__tests__/getManageAssetXDR.test.ts b/extension/src/popup/helpers/__tests__/getManageAssetXDR.test.ts new file mode 100644 index 0000000000..b910f7ada7 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/getManageAssetXDR.test.ts @@ -0,0 +1,38 @@ +import * as StellarSdk from "stellar-sdk"; + +import { buildChangeTrustOperation } from "../getManageAssetXDR"; + +describe("buildChangeTrustOperation", () => { + const assetCode = "USDC"; + const assetIssuer = + "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + + it("builds an add-trustline changeTrust op with no explicit limit", () => { + const xdrOp = buildChangeTrustOperation({ + assetCode, + assetIssuer, + sdk: StellarSdk, + }); + // Decode the XDR operation to verify its semantic content + const op = StellarSdk.Operation.fromXDRObject(xdrOp); + expect(op.type).toBe("changeTrust"); + // add-trustline: no explicit limit passed — SDK defaults to max trustline + expect((op as any).line.code).toBe(assetCode); + expect((op as any).line.issuer).toBe(assetIssuer); + // limit should be the SDK max (not "0"), meaning no cap was set + expect((op as any).limit).not.toBe("0"); + }); + + it("builds a remove-trustline changeTrust op with limit 0", () => { + const xdrOp = buildChangeTrustOperation({ + assetCode, + assetIssuer, + isRemove: true, + sdk: StellarSdk, + }); + const op = StellarSdk.Operation.fromXDRObject(xdrOp); + expect(op.type).toBe("changeTrust"); + // SDK decodes limit "0" as "0.0000000" (7 decimal places) + expect(parseFloat((op as any).limit)).toBe(0); + }); +}); diff --git a/extension/src/popup/helpers/getManageAssetXDR.ts b/extension/src/popup/helpers/getManageAssetXDR.ts index c6c758c869..4e4dcd958e 100644 --- a/extension/src/popup/helpers/getManageAssetXDR.ts +++ b/extension/src/popup/helpers/getManageAssetXDR.ts @@ -4,6 +4,26 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { xlmToStroop } from "helpers/stellar"; import { getSdk } from "@shared/helpers/stellar"; +type AnySdk = typeof StellarSdk | typeof StellarSdkNext; + +export const buildChangeTrustOperation = ({ + assetCode, + assetIssuer, + isRemove = false, + sdk, +}: { + assetCode: string; + assetIssuer: string; + isRemove?: boolean; + sdk: AnySdk; +}) => { + const changeParams = isRemove ? { limit: "0" } : {}; + return sdk.Operation.changeTrust({ + asset: new sdk.Asset(assetCode, assetIssuer), + ...changeParams, + }); +}; + export const getManageAssetXDR = async ({ publicKey, assetCode, @@ -25,7 +45,6 @@ export const getManageAssetXDR = async ({ timeout?: number; memo?: string; }) => { - const changeParams = addTrustline ? {} : { limit: "0" }; const sourceAccount = await server.loadAccount(publicKey); const Sdk = getSdk(networkDetails.networkPassphrase); @@ -35,9 +54,11 @@ export const getManageAssetXDR = async ({ networkPassphrase: networkDetails.networkPassphrase, }) .addOperation( - Sdk.Operation.changeTrust({ - asset: new Sdk.Asset(assetCode, assetIssuer), - ...changeParams, + buildChangeTrustOperation({ + assetCode, + assetIssuer, + isRemove: !addTrustline, + sdk: Sdk, }), ) .setTimeout(timeout); From 10a5792f8b354bc1a7c796e964a7f60cca079109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:49:31 -0300 Subject: [PATCH 019/121] Build atomic changeTrust+pathPayment swap tx with total-across-ops fee Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/useSimulateSwapData.test.ts | 108 +++++++++++++++++- .../SwapAmount/hooks/useSimulateSwapData.tsx | 61 ++++++++-- .../src/popup/helpers/getManageAssetXDR.ts | 2 +- 3 files changed, 160 insertions(+), 11 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts index 0bcbb34ae1..951fe3c891 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts @@ -1,4 +1,110 @@ -import { getSwapErrorMessage, ERROR_TO_DISPLAY } from "../useSimulateSwapData"; +import { Asset } from "stellar-sdk"; + +import { + getBuiltTx, + getPerOpBaseFee, + getSwapErrorMessage, + ERROR_TO_DISPLAY, +} from "../useSimulateSwapData"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; + +jest.mock("@shared/api/helpers/stellarSdkServer", () => ({ + stellarSdkServer: () => ({ + loadAccount: async (pk: string) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Account } = require("stellar-sdk"); + return new Account(pk, "1"); + }, + }), +})); + +const PUBLIC_KEY = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +const baseOpData = { + sourceAsset: Asset.native(), + destAsset: new Asset("USDC", USDC_ISSUER), + amount: "10", + allowedSlippage: "2", + destinationAmount: "9.5", + path: [] as string[], +}; + +describe("getPerOpBaseFee", () => { + it("divides total fee across ops in stroops", () => { + // 0.0002 XLM total over 2 ops = 2000 stroops / 2 = 1000 stroops + expect(getPerOpBaseFee("0.0002", 2)).toBe("1000"); + }); + + it("clamps to the 100-stroop network minimum", () => { + // 0.00001 XLM = 100 stroops over 2 ops = 50 -> clamped to 100 + expect(getPerOpBaseFee("0.00001", 2)).toBe("100"); + }); + + it("returns the full total for a single op", () => { + expect(getPerOpBaseFee("0.00001", 1)).toBe("100"); + }); +}); + +describe("getBuiltTx", () => { + it("builds a single pathPaymentStrictSend when not a new token", async () => { + const builder = await getBuiltTx( + PUBLIC_KEY, + { ...baseOpData, destinationTokenDetails: null }, + "0.00001", + 180, + TESTNET_NETWORK_DETAILS, + ); + const tx = builder.build(); + const ops = tx.operations; + expect(ops).toHaveLength(1); + expect(ops[0].type).toBe("pathPaymentStrictSend"); + expect(builder.baseFee).toBe("100"); // 1 op, full total + }); + + it("prepends changeTrust as op[0] for a new token", async () => { + const builder = await getBuiltTx( + PUBLIC_KEY, + { + ...baseOpData, + destinationTokenDetails: { + tokenCode: "USDC", + requiresTrustline: true, + decimals: 7, + issuer: USDC_ISSUER, + }, + }, + "0.0002", + 180, + TESTNET_NETWORK_DETAILS, + ); + const tx = builder.build(); + const ops = tx.operations; + expect(ops).toHaveLength(2); + expect(ops[0].type).toBe("changeTrust"); + expect(ops[1].type).toBe("pathPaymentStrictSend"); + expect(builder.baseFee).toBe("1000"); // 0.0002 XLM / 2 ops + }); + + it("throws when requiresTrustline but issuer is missing", async () => { + await expect( + getBuiltTx( + PUBLIC_KEY, + { + ...baseOpData, + destinationTokenDetails: { + tokenCode: "USDC", + requiresTrustline: true, + decimals: 7, + }, + }, + "0.0002", + 180, + TESTNET_NETWORK_DETAILS, + ), + ).rejects.toThrow(); + }); +}); const CONTRACT_ID = "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7"; const CLASSIC_ISSUER = diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 9d4aea6f53..4fff6596e9 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -18,6 +18,8 @@ import { xlmToStroop, } from "helpers/stellar"; import { computeDestMinWithSlippage } from "helpers/transaction"; +import { buildChangeTrustOperation } from "popup/helpers/getManageAssetXDR"; +import { getSdk } from "@shared/helpers/stellar"; import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; import { @@ -81,6 +83,23 @@ export interface SimulateTxData { scanResult?: BlockAidScanTxResult | null; } +export const MIN_PER_OP_FEE = 100; // network minimum, stroops + +type DestinationTokenDetails = { + tokenCode: string; + requiresTrustline: boolean; + decimals: number; + issuer?: string; +} | null; + +export const getPerOpBaseFee = (totalFee: string, opCount: number): string => { + const totalStroops = xlmToStroop(totalFee); + const perOp = totalStroops.dividedBy(opCount); + return BigNumber.max(perOp, new BigNumber(MIN_PER_OP_FEE)) + .integerValue(BigNumber.ROUND_FLOOR) + .toFixed(); +}; + const getOperation = ( sourceAsset: Asset | { code: string; issuer: string }, destAsset: Asset | { code: string; issuer: string }, @@ -104,7 +123,7 @@ const getOperation = ( }); }; -const getBuiltTx = async ( +export const getBuiltTx = async ( publicKey: string, opData: { sourceAsset: Asset | { code: string; issuer: string }; @@ -113,6 +132,7 @@ const getBuiltTx = async ( allowedSlippage: string; destinationAmount: string; path: string[]; + destinationTokenDetails: DestinationTokenDetails; }, fee: string, transactionTimeout: number, @@ -126,6 +146,7 @@ const getBuiltTx = async ( allowedSlippage, destinationAmount, path, + destinationTokenDetails, } = opData; const server = stellarSdkServer( networkDetails.networkUrl, @@ -133,6 +154,31 @@ const getBuiltTx = async ( ); const sourceAccount = await server.loadAccount(publicKey); + const requiresTrustline = !!destinationTokenDetails?.requiresTrustline; + const opCount = requiresTrustline ? 2 : 1; + + if (requiresTrustline && !destinationTokenDetails?.issuer) { + throw new Error( + "Cannot add a trustline for a destination token without an issuer", + ); + } + + const transaction = new TransactionBuilder(sourceAccount, { + fee: getPerOpBaseFee(fee, opCount), + networkPassphrase: networkDetails.networkPassphrase, + }); + + if (requiresTrustline) { + const Sdk = getSdk(networkDetails.networkPassphrase); + transaction.addOperation( + buildChangeTrustOperation({ + assetCode: destinationTokenDetails!.tokenCode, + assetIssuer: destinationTokenDetails!.issuer!, + sdk: Sdk, + }), + ); + } + const operation = getOperation( sourceAsset, destAsset, @@ -142,13 +188,7 @@ const getBuiltTx = async ( path, publicKey, ); - - const transaction = new TransactionBuilder(sourceAccount, { - fee: xlmToStroop(fee).toFixed(), - networkPassphrase: networkDetails.networkPassphrase, - }) - .addOperation(operation) - .setTimeout(transactionTimeout); + transaction.addOperation(operation).setTimeout(transactionTimeout); if (memo) { transaction.addMemo(Memo.text(memo)); @@ -166,7 +206,9 @@ function useSimulateTxData({ networkDetails: NetworkDetails; simParams: SimulationParams; }) { - const { memo } = useSelector(transactionDataSelector); + const { memo, destinationTokenDetails } = useSelector( + transactionDataSelector, + ); const reduxDispatch = useDispatch(); const { scanTx } = useScanTx(); @@ -234,6 +276,7 @@ function useSimulateTxData({ destinationAmount, allowedSlippage, path, + destinationTokenDetails, }, baseFee.toString(), transactionTimeout, diff --git a/extension/src/popup/helpers/getManageAssetXDR.ts b/extension/src/popup/helpers/getManageAssetXDR.ts index 4e4dcd958e..33bc22f733 100644 --- a/extension/src/popup/helpers/getManageAssetXDR.ts +++ b/extension/src/popup/helpers/getManageAssetXDR.ts @@ -4,7 +4,7 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { xlmToStroop } from "helpers/stellar"; import { getSdk } from "@shared/helpers/stellar"; -type AnySdk = typeof StellarSdk | typeof StellarSdkNext; +export type AnySdk = typeof StellarSdk | typeof StellarSdkNext; export const buildChangeTrustOperation = ({ assetCode, From 6e28b54bfffe6ec4b38dc073c60e03310f08884d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:52:46 -0300 Subject: [PATCH 020/121] Add quote-expiry op-code classification helper --- .../helpers/__tests__/quoteExpiry.test.ts | 41 +++++++++++++++++++ extension/src/popup/helpers/quoteExpiry.ts | 21 ++++++++++ 2 files changed, 62 insertions(+) create mode 100644 extension/src/popup/helpers/__tests__/quoteExpiry.test.ts create mode 100644 extension/src/popup/helpers/quoteExpiry.ts diff --git a/extension/src/popup/helpers/__tests__/quoteExpiry.test.ts b/extension/src/popup/helpers/__tests__/quoteExpiry.test.ts new file mode 100644 index 0000000000..dfc357b466 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/quoteExpiry.test.ts @@ -0,0 +1,41 @@ +import { + getQuoteExpiredOperationCodes, + isQuoteExpiredError, +} from "../quoteExpiry"; + +const makeError = (ops: string[]) => + ({ + response: { extras: { result_codes: { operations: ops } } }, + }) as any; + +describe("getQuoteExpiredOperationCodes", () => { + it("returns op_under_dest_min when present", () => { + expect( + getQuoteExpiredOperationCodes(makeError(["op_under_dest_min"])), + ).toEqual(["op_under_dest_min"]); + }); + + it("returns op_too_few_offers when present", () => { + expect( + getQuoteExpiredOperationCodes(makeError(["op_too_few_offers"])), + ).toEqual(["op_too_few_offers"]); + }); + + it("ignores unrelated op codes", () => { + expect( + getQuoteExpiredOperationCodes(makeError(["op_underfunded"])), + ).toEqual([]); + }); +}); + +describe("isQuoteExpiredError", () => { + it("is true for a quote-expiry op code", () => { + expect(isQuoteExpiredError(makeError(["op_too_few_offers"]))).toBe(true); + }); + it("is false for a generic failure", () => { + expect(isQuoteExpiredError(makeError(["op_underfunded"]))).toBe(false); + }); + it("is false for undefined", () => { + expect(isQuoteExpiredError(undefined)).toBe(false); + }); +}); diff --git a/extension/src/popup/helpers/quoteExpiry.ts b/extension/src/popup/helpers/quoteExpiry.ts new file mode 100644 index 0000000000..202bc2713b --- /dev/null +++ b/extension/src/popup/helpers/quoteExpiry.ts @@ -0,0 +1,21 @@ +import { ErrorMessage } from "@shared/api/types"; +import { getResultCodes } from "popup/helpers/parseTransaction"; + +// These Horizon op codes mean the frozen quote no longer clears at submit +// time; mobile's quoteErrors.ts uses the same set. +export const QUOTE_EXPIRED_OP_CODES = [ + "op_under_dest_min", + "op_too_few_offers", +]; + +export const getQuoteExpiredOperationCodes = ( + error: ErrorMessage | undefined, +): string[] => { + const { operations } = getResultCodes(error); + return (operations || []).filter((code) => + QUOTE_EXPIRED_OP_CODES.includes(code), + ); +}; + +export const isQuoteExpiredError = (error: ErrorMessage | undefined): boolean => + getQuoteExpiredOperationCodes(error).length > 0; From 73b84343a404119bc85829a5c832284c7e6f1e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:54:54 -0300 Subject: [PATCH 021/121] Add shouldShowXlmReservePreflight predicate --- .../helpers/__tests__/xlmReserve.test.ts | 55 +++++++++++++++++++ extension/src/popup/helpers/xlmReserve.ts | 25 +++++++++ 2 files changed, 80 insertions(+) create mode 100644 extension/src/popup/helpers/__tests__/xlmReserve.test.ts create mode 100644 extension/src/popup/helpers/xlmReserve.ts diff --git a/extension/src/popup/helpers/__tests__/xlmReserve.test.ts b/extension/src/popup/helpers/__tests__/xlmReserve.test.ts new file mode 100644 index 0000000000..8d36dac06f --- /dev/null +++ b/extension/src/popup/helpers/__tests__/xlmReserve.test.ts @@ -0,0 +1,55 @@ +import { shouldShowXlmReservePreflight } from "../xlmReserve"; + +describe("shouldShowXlmReservePreflight", () => { + it("returns false when the destination is not new", () => { + expect( + shouldShowXlmReservePreflight({ + requiresTrustline: false, + sourceIsXlm: true, + spendableXlm: "0", + }), + ).toBe(false); + }); + + describe("XLM source (gate on < 0.5)", () => { + it("shows when spendable XLM is below the base reserve", () => { + expect( + shouldShowXlmReservePreflight({ + requiresTrustline: true, + sourceIsXlm: true, + spendableXlm: "0.4", + }), + ).toBe(true); + }); + it("does not show at exactly the base reserve", () => { + expect( + shouldShowXlmReservePreflight({ + requiresTrustline: true, + sourceIsXlm: true, + spendableXlm: "0.5", + }), + ).toBe(false); + }); + }); + + describe("non-XLM source (gate on <= 0.5)", () => { + it("shows when XLM headroom is at or below the base reserve", () => { + expect( + shouldShowXlmReservePreflight({ + requiresTrustline: true, + sourceIsXlm: false, + spendableXlm: "0.5", + }), + ).toBe(true); + }); + it("does not show with headroom above the base reserve", () => { + expect( + shouldShowXlmReservePreflight({ + requiresTrustline: true, + sourceIsXlm: false, + spendableXlm: "0.6", + }), + ).toBe(false); + }); + }); +}); diff --git a/extension/src/popup/helpers/xlmReserve.ts b/extension/src/popup/helpers/xlmReserve.ts new file mode 100644 index 0000000000..53e5d246ac --- /dev/null +++ b/extension/src/popup/helpers/xlmReserve.ts @@ -0,0 +1,25 @@ +import BigNumber from "bignumber.js"; + +import { BASE_RESERVE } from "@shared/constants/stellar"; + +// Pre-flight: does a NEW-token swap risk failing on-chain because the source +// account can't cover the extra 0.5 XLM trustline reserve? §3.6. +export const shouldShowXlmReservePreflight = ({ + requiresTrustline, + sourceIsXlm, + spendableXlm, +}: { + requiresTrustline: boolean; + sourceIsXlm: boolean; + spendableXlm: string; +}): boolean => { + if (!requiresTrustline) { + return false; + } + const spendable = new BigNumber(spendableXlm); + const reserve = new BigNumber(BASE_RESERVE); + if (sourceIsXlm) { + return spendable.lt(reserve); + } + return spendable.lte(reserve); +}; From 8c557fd536f14e9ff07df7f4574992b17db1e01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 18:57:30 -0300 Subject: [PATCH 022/121] Add horizonGetBestReceivePath for XLM-reserve prefill --- .../__tests__/horizonGetBestPath.test.ts | 35 ++++++++++++++++++- .../src/popup/helpers/horizonGetBestPath.ts | 25 +++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/helpers/__tests__/horizonGetBestPath.test.ts b/extension/src/popup/helpers/__tests__/horizonGetBestPath.test.ts index f7fe9b351e..d7eac0e847 100644 --- a/extension/src/popup/helpers/__tests__/horizonGetBestPath.test.ts +++ b/extension/src/popup/helpers/__tests__/horizonGetBestPath.test.ts @@ -1,7 +1,10 @@ import { Asset, Horizon } from "stellar-sdk"; import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; -import { horizonGetBestPath } from "../horizonGetBestPath"; +import { + horizonGetBestPath, + horizonGetBestReceivePath, +} from "../horizonGetBestPath"; import { getAssetFromCanonical } from "helpers/stellar"; import { cleanAmount } from "../formatters"; @@ -18,12 +21,18 @@ jest.mock("stellar-sdk", () => { }; }, ); + const mockStrictReceivePaths = jest.fn( + (_source: Asset[], _destAsset: Asset, _destAmount: string) => ({ + call: async () => ({ records: [] }), + }), + ); return { ...original, Horizon: { Server: class Server { constructor(_networkUrl: string) {} strictSendPaths = mockStrictSendPaths; + strictReceivePaths = mockStrictReceivePaths; }, }, }; @@ -53,3 +62,27 @@ describe("horizonGetBestPath", () => { expect(server.strictSendPaths).toHaveBeenCalledWith(...expected); }); }); + +describe("horizonGetBestReceivePath", () => { + it("calls strictReceivePaths with the cleaned destination amount", async () => { + const uncleanDest = "0,5000"; + const server = new Horizon.Server(TESTNET_NETWORK_DETAILS.networkUrl); + const source = + "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + const dest = "native"; + + await horizonGetBestReceivePath({ + destinationAmount: uncleanDest, + sourceAsset: source, + destAsset: dest, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + + const expected = [ + [getAssetFromCanonical(source) as Asset], + getAssetFromCanonical(dest) as Asset, + cleanAmount(uncleanDest), + ]; + expect(server.strictReceivePaths).toHaveBeenCalledWith(...expected); + }); +}); diff --git a/extension/src/popup/helpers/horizonGetBestPath.ts b/extension/src/popup/helpers/horizonGetBestPath.ts index 3ab3f254ac..7ad466f919 100644 --- a/extension/src/popup/helpers/horizonGetBestPath.ts +++ b/extension/src/popup/helpers/horizonGetBestPath.ts @@ -24,3 +24,28 @@ export const horizonGetBestPath = async ({ const paths = await builder.call(); return paths.records[0]; }; + +// Reverse path-find: how much of `sourceAsset` to send to RECEIVE +// `destinationAmount` of `destAsset`. Used by the XLM-reserve sheet to +// pre-fill a "swap for ~0.5 XLM" amount. +export const horizonGetBestReceivePath = async ({ + destinationAmount, + sourceAsset, + destAsset, + networkDetails, +}: { + destinationAmount: string; + sourceAsset: string; + destAsset: string; + networkDetails: NetworkDetails; +}) => { + const server = new Horizon.Server(networkDetails.networkUrl); + const builder = server.strictReceivePaths( + [getAssetFromCanonical(sourceAsset)] as Asset[], + getAssetFromCanonical(destAsset) as Asset, + cleanAmount(destinationAmount), + ); + + const paths = await builder.call(); + return paths.records[0]; +}; From 4998ae7c9b518603aea9e36ba6930a5a1edf4691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 19:01:23 -0300 Subject: [PATCH 023/121] Add XlmReserveSheet (swap for 0.5 XLM, copy address, why-XLM link) Co-Authored-By: Claude Opus 4.8 --- .../__tests__/XlmReserveSheet.test.tsx | 59 ++++++++++++++ .../components/swap/XlmReserveSheet/index.tsx | 80 +++++++++++++++++++ .../swap/XlmReserveSheet/styles.scss | 18 +++++ .../src/popup/locales/en/translation.json | 5 ++ .../src/popup/locales/pt/translation.json | 5 ++ 5 files changed, 167 insertions(+) create mode 100644 extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx create mode 100644 extension/src/popup/components/swap/XlmReserveSheet/index.tsx create mode 100644 extension/src/popup/components/swap/XlmReserveSheet/styles.scss diff --git a/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx b/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx new file mode 100644 index 0000000000..edb87c4519 --- /dev/null +++ b/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { XlmReserveSheet } from "../index"; +import { openTab } from "popup/helpers/navigate"; + +jest.mock("popup/helpers/navigate", () => ({ + openTab: jest.fn(), +})); + +const PUBLIC_KEY = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const HELP_URL = "https://example.test/why-xlm"; + +describe("XlmReserveSheet", () => { + afterEach(() => jest.clearAllMocks()); + + it("renders the Swap-for-0.5-XLM action when a source qualifies", () => { + const onSwapForReserve = jest.fn(); + render( + , + ); + const btn = screen.getByTestId("XlmReserveSheet__swap-for-reserve"); + fireEvent.click(btn); + expect(onSwapForReserve).toHaveBeenCalledTimes(1); + }); + + it("hides the Swap-for-0.5-XLM action when no source qualifies", () => { + render( + , + ); + expect( + screen.queryByTestId("XlmReserveSheet__swap-for-reserve"), + ).toBeNull(); + }); + + it("opens the help article in a new tab", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("XlmReserveSheet__why-xlm")); + expect(openTab).toHaveBeenCalledWith(HELP_URL); + }); +}); diff --git a/extension/src/popup/components/swap/XlmReserveSheet/index.tsx b/extension/src/popup/components/swap/XlmReserveSheet/index.tsx new file mode 100644 index 0000000000..3521c743e9 --- /dev/null +++ b/extension/src/popup/components/swap/XlmReserveSheet/index.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Button, CopyText, Icon, Text } from "@stellar/design-system"; + +import { openTab } from "popup/helpers/navigate"; + +import "./styles.scss"; + +interface XlmReserveSheetProps { + canSwapForReserve: boolean; + onSwapForReserve?: () => void; + publicKey: string; + helpUrl: string; + onClose: () => void; +} + +export const XlmReserveSheet = ({ + canSwapForReserve, + onSwapForReserve, + publicKey, + helpUrl, + onClose, +}: XlmReserveSheetProps) => { + const { t } = useTranslation(); + + return ( +
+
+ + {t("You need XLM to create a trustline")} + + + {t( + "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.", + )} + +
+ +
+ {canSwapForReserve ? ( + + ) : null} + + + + + + +
+
+ ); +}; diff --git a/extension/src/popup/components/swap/XlmReserveSheet/styles.scss b/extension/src/popup/components/swap/XlmReserveSheet/styles.scss new file mode 100644 index 0000000000..18f345d23e --- /dev/null +++ b/extension/src/popup/components/swap/XlmReserveSheet/styles.scss @@ -0,0 +1,18 @@ +.XlmReserveSheet { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.5rem; + + &__header { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + } +} diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 1641088251..232936de9a 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -39,6 +39,7 @@ "Add Trustline": "Add Trustline", "Add trustline icon": "Add trustline icon", "Add XLM": "Add XLM", + "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.": "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.", "Adding this token is not possible at the moment.": "Adding this token is not possible at the moment.", "Additional details": "Additional details", "Address": "Address", @@ -148,6 +149,7 @@ "Copied!": "Copied!", "COPY": "COPY", "Copy address": "Copy address", + "Copy my wallet address": "Copy my wallet address", "Cost to migrate": "Cost to migrate", "Couldn’t clear recent dApps": "Couldn’t clear recent dApps", "Couldn’t open this dApp": "Couldn’t open this dApp", @@ -580,6 +582,7 @@ "Swap destination": "Swap destination", "Swap destination token logo": "Swap destination token logo", "Swap failed": "Swap failed", + "Swap for 0.5 XLM": "Swap for 0.5 XLM", "Swap from": "Swap from", "Swap Settings": "Swap Settings", "Swap source": "Swap source", @@ -737,6 +740,7 @@ "Welcome to Discover!": "Welcome to Discover!", "What is this transaction for? (optional)": "What is this transaction for? (optional)", "What’s new": "What’s new", + "Why do I need XLM?": "Why do I need XLM?", "Wrong simulation result": "Wrong simulation result", "XDR": "XDR", "XLM": "XLM", @@ -758,6 +762,7 @@ "You may not be able to transact with Soroban smart contracts or see your Soroban tokens at this time.": "You may not be able to transact with Soroban smart contracts or see your Soroban tokens at this time.", "You must have a balance of": "You must have a balance of", "You must have a buying liability of": "You must have a buying liability of", + "You need XLM to create a trustline": "You need XLM to create a trustline", "You previously did not complete onboarding.": "You previously did not complete onboarding.", "You still have a balance of": "You still have a balance of", "You still have a buying liability of": "You still have a buying liability of", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index f0cfb778fa..96ee07a343 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -39,6 +39,7 @@ "Add Trustline": "Adicionar Trustline", "Add trustline icon": "Ícone adicionar trustline", "Add XLM": "Adicionar XLM", + "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.": "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.", "Adding this token is not possible at the moment.": "Adicionar este token não é possível no momento.", "Additional details": "Detalhes adicionais", "Address": "Endereço", @@ -148,6 +149,7 @@ "Copied!": "Copiado!", "COPY": "COPIAR", "Copy address": "Copiar endereço", + "Copy my wallet address": "Copy my wallet address", "Cost to migrate": "Custo para migrar", "Couldn’t clear recent dApps": "Não foi possível limpar os dApps recentes", "Couldn’t open this dApp": "Não foi possível abrir este dApp", @@ -580,6 +582,7 @@ "Swap destination": "Destino da troca", "Swap destination token logo": "Logotipo do token de destino da troca", "Swap failed": "Troca falhou", + "Swap for 0.5 XLM": "Swap for 0.5 XLM", "Swap from": "Trocar de", "Swap Settings": "Configurações de Troca", "Swap source": "Origem da troca", @@ -735,6 +738,7 @@ "Welcome to Discover!": "Bem-vindo ao Discover!", "What is this transaction for? (optional)": "Para que é esta transação? (opcional)", "What’s new": "O que há de novo", + "Why do I need XLM?": "Why do I need XLM?", "Wrong simulation result": "Resultado de simulação incorreto", "XDR": "XDR", "XLM": "XLM", @@ -756,6 +760,7 @@ "You may not be able to transact with Soroban smart contracts or see your Soroban tokens at this time.": "Você talvez não consiga realizar transações com contratos inteligentes Soroban ou ver seus tokens Soroban neste momento.", "You must have a balance of": "Você deve ter um saldo de", "You must have a buying liability of": "Você deve ter um passivo de compra de", + "You need XLM to create a trustline": "You need XLM to create a trustline", "You previously did not complete onboarding.": "Você anteriormente não completou o onboarding.", "You still have a balance of": "Você ainda tem um saldo de", "You still have a buying liability of": "Você ainda tem um passivo de compra de", From 5da1848670c3d9093e70873ba7424da452aa9c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 19:06:26 -0300 Subject: [PATCH 024/121] [Extension] Add swap-to-new-token telemetry event names Co-Authored-By: Claude Opus 4.8 --- .../constants/__tests__/metricsNames.test.ts | 15 +++++++++++++++ extension/src/popup/constants/metricsNames.ts | 7 +++++++ 2 files changed, 22 insertions(+) create mode 100644 extension/src/popup/constants/__tests__/metricsNames.test.ts diff --git a/extension/src/popup/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts new file mode 100644 index 0000000000..805df56fa6 --- /dev/null +++ b/extension/src/popup/constants/__tests__/metricsNames.test.ts @@ -0,0 +1,15 @@ +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +describe("METRIC_NAMES swap-to-new-token events", () => { + it("defines the new swap events", () => { + expect(METRIC_NAMES.swapPickerOpened).toBe("swap: picker opened"); + expect(METRIC_NAMES.swapSourceSelected).toBe("swap: source selected"); + expect(METRIC_NAMES.swapDestinationSelected).toBe( + "swap: destination selected", + ); + expect(METRIC_NAMES.swapDirectionToggled).toBe("swap: direction toggled"); + expect(METRIC_NAMES.swapTrustlineAdded).toBe("swap: trustline added"); + expect(METRIC_NAMES.swapXlmReserveShown).toBe("swap: xlm reserve shown"); + expect(METRIC_NAMES.swapQuoteExpired).toBe("swap: quote expired"); + }); +}); diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 35821e541d..0bdafd881c 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -77,6 +77,13 @@ export const METRIC_NAMES = { swapSettingsSlippage: "loaded screen: swap settings slippage", swapSettingsTimeout: "loaded screen: swap settings timeout", swapConfirm: "loaded screen: swap confirm", + swapPickerOpened: "swap: picker opened", + swapSourceSelected: "swap: source selected", + swapDestinationSelected: "swap: destination selected", + swapDirectionToggled: "swap: direction toggled", + swapTrustlineAdded: "swap: trustline added", + swapXlmReserveShown: "swap: xlm reserve shown", + swapQuoteExpired: "swap: quote expired", viewAddCollectibles: "loaded screen: add collectibles", viewSendCollectible: "loaded screen: send collectible", From 939c6c7ad3bf94b99cedb0b0a841d19ea339286c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 19:08:23 -0300 Subject: [PATCH 025/121] [Extension] Add calculateSwapRate helper for swap review rate row Co-Authored-By: Claude Opus 4.8 --- .../__tests__/calculateSwapRate.test.ts | 21 +++++++++++++++++++ .../helpers/calculateSwapRate.ts | 18 ++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/__tests__/calculateSwapRate.test.ts create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/calculateSwapRate.ts diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/__tests__/calculateSwapRate.test.ts b/extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/__tests__/calculateSwapRate.test.ts new file mode 100644 index 0000000000..09a3995e61 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/__tests__/calculateSwapRate.test.ts @@ -0,0 +1,21 @@ +import { calculateSwapRate } from "../calculateSwapRate"; + +describe("calculateSwapRate", () => { + it("returns destinationAmount / sendAmount formatted", () => { + expect( + calculateSwapRate({ sendAmount: "10", destinationAmount: "25" }), + ).toBe("2.5"); + }); + + it("returns 0 when sendAmount is zero", () => { + expect( + calculateSwapRate({ sendAmount: "0", destinationAmount: "25" }), + ).toBe("0"); + }); + + it("returns 0 when sendAmount is empty", () => { + expect(calculateSwapRate({ sendAmount: "", destinationAmount: "25" })).toBe( + "0", + ); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/calculateSwapRate.ts b/extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/calculateSwapRate.ts new file mode 100644 index 0000000000..2eab561699 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/helpers/calculateSwapRate.ts @@ -0,0 +1,18 @@ +import BigNumber from "bignumber.js"; + +import { formatAmount } from "popup/helpers/formatters"; + +export const calculateSwapRate = ({ + sendAmount, + destinationAmount, +}: { + sendAmount: string; + destinationAmount: string; +}): string => { + const send = new BigNumber(sendAmount || "0"); + if (send.isZero() || send.isNaN()) { + return "0"; + } + const rate = new BigNumber(destinationAmount || "0").dividedBy(send); + return formatAmount(rate.decimalPlaces(7).toString()); +}; From 1fc903da83582b48286a076ab86180876267f32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 19:14:39 -0300 Subject: [PATCH 026/121] [Extension] Add trustline banner + info sheet for swap review Co-Authored-By: Claude Opus 4.8 --- .../components/TrustlineBanner.tsx | 32 +++++++++++++ .../components/TrustlineInfoSheet.tsx | 46 ++++++++++++++++++ .../__tests__/TrustlineBanner.test.tsx | 21 +++++++++ .../__tests__/TrustlineInfoSheet.test.tsx | 47 +++++++++++++++++++ .../ReviewTransaction/components/index.ts | 2 + .../ReviewTransaction/styles.scss | 14 ++++++ .../src/popup/locales/en/translation.json | 4 ++ .../src/popup/locales/pt/translation.json | 4 ++ 8 files changed, 170 insertions(+) create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineBanner.test.tsx create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx new file mode 100644 index 0000000000..5c913d8746 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Icon, Notification } from "@stellar/design-system"; + +interface TrustlineBannerProps { + tokenCode: string; + onClick: () => void; +} + +export const TrustlineBanner = ({ + tokenCode, + onClick, +}: TrustlineBannerProps) => { + const { t } = useTranslation(); + return ( +
+ } + title={t("This will add a trustline to {{code}}", { code: tokenCode })} + > +
+ +
+
+
+ ); +}; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx new file mode 100644 index 0000000000..f5f36b1029 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@stellar/design-system"; + +interface TrustlineInfoSheetProps { + tokenCode: string; + onClose: () => void; +} + +export const TrustlineInfoSheet = ({ + tokenCode, + onClose, +}: TrustlineInfoSheetProps) => { + const { t } = useTranslation(); + return ( +
+
+
+ +
+
+ +
+
+
+ {t("Adding a trustline to {{code}}", { code: tokenCode })} +
+
+
+ {t( + "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", + )} +
+
+ {t( + "This reserve is refundable. Remove the trustline later to get it back.", + )} +
+
+
+ ); +}; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineBanner.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineBanner.test.tsx new file mode 100644 index 0000000000..6ece8a6fad --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineBanner.test.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { Wrapper } from "popup/__testHelpers__"; +import { TrustlineBanner } from "../TrustlineBanner"; + +describe("TrustlineBanner", () => { + it("shows the token code and fires onClick", () => { + const onClick = jest.fn(); + render( + + + , + ); + expect( + screen.getByText("This will add a trustline to {{code}}"), + ).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("review-tx-trustline-banner")); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx new file mode 100644 index 0000000000..ef4aba0e43 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { Wrapper } from "popup/__testHelpers__"; +import { TrustlineInfoSheet } from "../TrustlineInfoSheet"; + +describe("TrustlineInfoSheet", () => { + it("renders the info sheet", () => { + const onClose = jest.fn(); + render( + + + , + ); + expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); + }); + + it("renders the 0.5 XLM reserve line and the refundable line", () => { + const onClose = jest.fn(); + render( + + + , + ); + expect( + screen.getByText( + "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + "This reserve is refundable. Remove the trustline later to get it back.", + ), + ).toBeInTheDocument(); + }); + + it("fires onClose when the close button is clicked", () => { + const onClose = jest.fn(); + render( + + + , + ); + fireEvent.click(screen.getByTestId("trustline-info-sheet-close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/index.ts b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/index.ts index 30ebcdfdb1..53734d2499 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/index.ts +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/index.ts @@ -1,3 +1,5 @@ export { SendAsset } from "./SendAsset"; export { SendDestination } from "./SendDestination"; export { ActionButtons } from "./ActionButtons"; +export { TrustlineBanner } from "./TrustlineBanner"; +export { TrustlineInfoSheet } from "./TrustlineInfoSheet"; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss index 06349fed1d..ef2063a445 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss @@ -290,4 +290,18 @@ color: var(--sds-clr-gray-12); } } + + &__TrustlineBanner { + cursor: pointer; + + .Notification { + background-color: var(--sds-clr-lilac-03, #2a2140); + border-color: var(--sds-clr-lilac-06, #5b3aa8); + } + + &__Action { + display: flex; + justify-content: flex-end; + } + } } diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 232936de9a..da0005cfba 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -40,6 +40,7 @@ "Add trustline icon": "Add trustline icon", "Add XLM": "Add XLM", "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.": "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.", + "Adding a trustline to {{code}}": "Adding a trustline to {{code}}", "Adding this token is not possible at the moment.": "Adding this token is not possible at the moment.", "Additional details": "Additional details", "Address": "Address", @@ -630,6 +631,7 @@ "This can be used to sign arbitrary transaction hashes without having to decode them first.": "This can be used to sign arbitrary transaction hashes without having to decode them first.", "This collectible is hidden": "This collectible is hidden", "This is not a valid contract id.": "This is not a valid contract id.", + "This reserve is refundable. Remove the trustline later to get it back.": "This reserve is refundable. Remove the trustline later to get it back.", "This setting enables access to the Futurenet network and disables access to Pubnet.": "This setting enables access to the Futurenet network and disables access to Pubnet.", "This site does not appear safe for the following reasons": "This site does not appear safe for the following reasons", "This site has been flagged with potential concerns": "This site has been flagged with potential concerns", @@ -644,12 +646,14 @@ "This transaction was flagged as malicious (override active)": "This transaction was flagged as malicious (override active)", "This transaction was flagged as suspicious": "This transaction was flagged as suspicious", "This transaction was flagged as suspicious (override active)": "This transaction was flagged as suspicious (override active)", + "This will add a trustline to {{code}}": "This will add a trustline to {{code}}", "This will be used to unlock your wallet": "This will be used to unlock your wallet", "Timeout": "Timeout", "Timeout (seconds)": "Timeout (seconds)", "to": "to", "To access your wallet, click Freighter from your browser Extensions browser menu.": "To access your wallet, click Freighter from your browser Extensions browser menu.", "To create a new account you need to send at least 1 XLM to it.": "To create a new account you need to send at least 1 XLM to it.", + "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", "To start using this account, fund it with at least 1 XLM.": "To start using this account, fund it with at least 1 XLM.", "Toggle Assets": "Toggle Assets", "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 96ee07a343..16dc3f6945 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -40,6 +40,7 @@ "Add trustline icon": "Ícone adicionar trustline", "Add XLM": "Adicionar XLM", "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.": "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.", + "Adding a trustline to {{code}}": "Adicionando uma linha de confiança para {{code}}", "Adding this token is not possible at the moment.": "Adicionar este token não é possível no momento.", "Additional details": "Detalhes adicionais", "Address": "Endereço", @@ -630,6 +631,7 @@ "This can be used to sign arbitrary transaction hashes without having to decode them first.": "Isso pode ser usado para assinar hashes de transação arbitrários sem precisar decodificá-los primeiro.", "This collectible is hidden": "Este colecionável está oculto", "This is not a valid contract id.": "Este não é um ID de contrato válido.", + "This reserve is refundable. Remove the trustline later to get it back.": "Essa reserva é reembolsável. Remova a linha de confiança depois para recuperá-la.", "This setting enables access to the Futurenet network and disables access to Pubnet.": "Esta configuração permite acesso à rede Futurenet e desabilita o acesso ao Pubnet.", "This site does not appear safe for the following reasons": "Este site não parece seguro pelos seguintes motivos", "This site has been flagged with potential concerns": "Este site foi sinalizado com possíveis preocupações", @@ -644,12 +646,14 @@ "This transaction was flagged as malicious (override active)": "Esta transação foi sinalizada como maliciosa (substituição ativa)", "This transaction was flagged as suspicious": "Esta transação foi marcada como suspeita", "This transaction was flagged as suspicious (override active)": "Esta transação foi sinalizada como suspeita (substituição ativa)", + "This will add a trustline to {{code}}": "Isto vai adicionar uma linha de confiança para {{code}}", "This will be used to unlock your wallet": "Isso será usado para desbloquear sua carteira", "Timeout": "Tempo esgotado", "Timeout (seconds)": "Timeout (segundos)", "to": "para", "To access your wallet, click Freighter from your browser Extensions browser menu.": "Para acessar sua carteira, clique em Freighter no menu de Extensões do seu navegador.", "To create a new account you need to send at least 1 XLM to it.": "Para criar uma nova conta, você precisa enviar pelo menos 1 XLM para ela.", + "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "Para manter um novo ativo, sua conta reserva uma única vez 0,5 XLM para a linha de confiança.", "To start using this account, fund it with at least 1 XLM.": "Para começar a usar esta conta, financie-a com pelo menos 1 XLM.", "Toggle Assets": "Alternar Ativos", "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", From 9c6dbc1538b979b2f5fccaaad1bbab6c7c580b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 19:30:54 -0300 Subject: [PATCH 027/121] [Extension] Wire trustline banner, info sheet & rate row into ReviewTx Co-Authored-By: Claude Opus 4.8 --- .../ReviewTx.trustlineBanner.test.tsx | 82 +++++++++++++++++++ .../components/SwapRateRow.tsx | 53 ++++++++++++ .../ReviewTransaction/index.tsx | 44 +++++++++- .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx new file mode 100644 index 0000000000..b47f77a565 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { RequestState } from "constants/request"; +import { Wrapper } from "popup/__testHelpers__"; +import { ReviewTx } from "popup/components/InternalTransaction/ReviewTransaction"; + +const baseProps = { + assetIcon: null, + fee: "0.001", + sendAmount: "10", + sendPriceUsd: null, + srcAsset: "native", + networkDetails: { + network: "TESTNET", + networkName: "Test Net", + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: "https://horizon-testnet.stellar.org", + } as any, + title: "You are swapping", + onConfirm: jest.fn(), + onCancel: jest.fn(), + simulationState: { + state: RequestState.SUCCESS, + data: { + transactionXdr: "AAAA", + dstAmountPriceUsd: "0", + scanResult: null, + }, + error: null, + } as any, + dstAsset: { + icon: null, + canonical: "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + priceUsd: null, + amount: "25", + }, + destMin: "24.5", +}; + +describe("ReviewTx trustline banner", () => { + it("renders the banner when destination requires a trustline", () => { + render( + + + , + ); + const banner = screen.getByTestId("review-tx-trustline-banner"); + // i18n interpolation is not processed in the test environment; + // confirm the banner element is present (tokenCode wired) and clickable + expect(banner).toBeInTheDocument(); + fireEvent.click(banner); + expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); + }); + + it("does not render the banner when no trustline is required", () => { + render( + + + , + ); + expect( + screen.queryByTestId("review-tx-trustline-banner"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx new file mode 100644 index 0000000000..b83bf51fb7 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@stellar/design-system"; + +import { formatAmount } from "popup/helpers/formatters"; +import { calculateSwapRate } from "../helpers/calculateSwapRate"; + +interface SwapRateRowProps { + srcCode: string; + dstCode: string; + sendAmount: string; + destinationAmount: string; + destMin: string; +} + +export const SwapRateRow = ({ + srcCode, + dstCode, + sendAmount, + destinationAmount, + destMin, +}: SwapRateRowProps) => { + const { t } = useTranslation(); + const rate = calculateSwapRate({ sendAmount, destinationAmount }); + return ( + <> +
+
+ + {t("Rate")} +
+
+ {`1 ${srcCode} ≈ ${rate} ${dstCode}`} +
+
+
+
+ + {t("Minimum received")} +
+
+ {`${formatAmount(destMin)} ${dstCode}`} +
+
+ + ); +}; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 7857618a0b..33d65237d3 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -43,6 +43,9 @@ import { trackSendFeeBreakdownOpened } from "popup/metrics/send"; import { FeesPane } from "popup/components/InternalTransaction/FeesPane"; import { ActionButtons } from "./components/ActionButtons"; import { SendAsset, SendDestination } from "./components"; +import { TrustlineBanner } from "./components/TrustlineBanner"; +import { TrustlineInfoSheet } from "./components/TrustlineInfoSheet"; +import { SwapRateRow } from "./components/SwapRateRow"; import "./styles.scss"; @@ -107,6 +110,13 @@ interface ReviewTxProps { onConfirm: () => void; onCancel: () => void; onAddMemo?: () => void; + destinationTokenDetails?: { + tokenCode: string; + requiresTrustline: boolean; + decimals: number; + issuer?: string; + } | null; + destMin?: string; } export const ReviewTx = ({ @@ -122,6 +132,8 @@ export const ReviewTx = ({ onConfirm, onCancel, onAddMemo, + destinationTokenDetails, + destMin, }: ReviewTxProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -210,6 +222,9 @@ export const ReviewTx = ({ const isOnFeesPane = activePaneIndex === paneConfig.feesIndex; + const requiresTrustline = !!destinationTokenDetails?.requiresTrustline; + const [isOnTrustlinePane, setIsOnTrustlinePane] = useState(false); + // Extract contract ID for custom tokens or collectibles const contractId = React.useMemo( () => @@ -350,6 +365,12 @@ export const ReviewTx = ({ onClick={() => setActivePaneIndex(paneConfig.memoIndex)} /> )} + {requiresTrustline && ( + setIsOnTrustlinePane(true)} + /> + )}
{/* Hide memo row when memo is disabled (e.g., for all M addresses) */} @@ -392,6 +413,15 @@ export const ReviewTx = ({ {fee} XLM
+ {dstAsset && destMin && dest && ( + + )}
@@ -459,11 +489,19 @@ export const ReviewTx = ({ // Build panes in order (no hooks on JSX) const panes: React.ReactNode[] = []; - if (shouldShowTxWarning) { + if (isOnTrustlinePane) { + panes.push( + setIsOnTrustlinePane(false)} + />, + ); + } else if (shouldShowTxWarning) { panes.push(reviewPane, memoPane, blockaidPane, feesPane); } else { panes.push(reviewPane, memoPane, feesPane); } + const trustlineActiveIndex = isOnTrustlinePane ? 0 : activePaneIndex; return ( @@ -475,8 +513,8 @@ export const ReviewTx = ({ /> ) : (
- - {!isOnFeesPane && ( + + {!isOnFeesPane && !isOnTrustlinePane && (
Date: Wed, 24 Jun 2026 19:42:05 -0300 Subject: [PATCH 028/121] [Extension] Render SwapAmount on shared AmountCard + PercentageButtons + 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 --- .../__tests__/SwapAmount.layout.test.tsx | 56 +++ .../components/swap/SwapAmount/index.tsx | 398 +++++++----------- .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + 4 files changed, 217 insertions(+), 241 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx new file mode 100644 index 0000000000..bdd7822e01 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [] }, + tokenPrices: {}, +}; + +describe("SwapAmount layout", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + }); + afterEach(() => jest.restoreAllMocks()); + + it("renders two amount cards, percentage buttons and direction chevron", () => { + render( + + + , + ); + expect(screen.getByTestId("swap-sell-card")).toBeInTheDocument(); + expect(screen.getByTestId("swap-receive-card")).toBeInTheDocument(); + expect(screen.getByTestId("swap-direction-chevron")).toBeInTheDocument(); + expect(screen.getByTestId("swap-percentage-buttons")).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 9c1d4120ae..60196d21b9 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Navigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -16,11 +16,12 @@ import { import { View } from "popup/basics/layout/View"; import { SubviewHeader } from "popup/components/SubviewHeader"; import { useNetworkFees } from "popup/helpers/useNetworkFees"; -import { useRunAfterUpdate } from "popup/helpers/useRunAfterUpdate"; import { saveAllowedSlippage, saveAmount, saveAmountUsd, + saveAsset, + saveDestinationAsset, saveTransactionFee, saveTransactionTimeout, transactionDataSelector, @@ -29,7 +30,6 @@ import { import { cleanAmount, formatAmount, - formatAmountPreserveCursor, roundUsdValue, } from "popup/helpers/formatters"; import { TX_SEND_MAX } from "popup/constants/transaction"; @@ -54,12 +54,18 @@ import { useSimulateTxData } from "./hooks/useSimulateSwapData"; import { publicKeySelector } from "popup/ducks/accountServices"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { SlideupModal } from "popup/components/SlideupModal"; -import { AssetTile } from "popup/components/AssetTile"; +import { AmountCard } from "popup/components/amount/AmountCard"; +import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import "./styles.scss"; const defaultSlippage = "2"; -const DEFAULT_INPUT_WIDTH = 25; + +const AVAILABLE_BALANCE_FONT_SIZES = [ + { maxLen: 28, sizePx: 14 }, + { maxLen: 42, sizePx: 12 }, + { maxLen: Infinity, sizePx: 11 }, +] as const; interface SwapAmountProps { inputType: InputType; @@ -81,7 +87,6 @@ export const SwapAmount = ({ const { t } = useTranslation(); const dispatch = useDispatch(); const { networkCongestion, recommendedFee } = useNetworkFees(); - const runAfterUpdate = useRunAfterUpdate(); const networkDetails = useSelector(settingsNetworkDetailsSelector); const publicKey = useSelector(publicKeySelector); const { transactionData } = useSelector(transactionSubmissionSelector); @@ -127,24 +132,6 @@ export const SwapAmount = ({ memo, }, }); - const cryptoInputRef = useRef(null); - const usdInputRef = useRef(null); - - const [inputWidthCrypto, setInputWidthCrypto] = useState(0); - const setCryptoSpan = (el: HTMLSpanElement | null) => { - if (el) { - const width = el.offsetWidth + 4; - setInputWidthCrypto(Math.max(DEFAULT_INPUT_WIDTH, width)); - } - }; - - const [inputWidthFiat, setInputWidthFiat] = useState(0); - const setFiatSpan = (el: HTMLSpanElement | null) => { - if (el) { - const width = el.offsetWidth + 2; - setInputWidthFiat(Math.max(DEFAULT_INPUT_WIDTH, width)); - } - }; const [isEditingSlippage, setIsEditingSlippage] = useState(false); const [isEditingSettings, setIsEditingSettings] = useState(false); @@ -181,34 +168,25 @@ export const SwapAmount = ({ validateOnChange: true, }); - const getAmountFontSize = () => { - const length = formik.values.amount.length; - if (length <= 9) { - return ""; + const getAmountFontSizeClass = (): "lg" | "med" | "small" | "xsmall" => { + const currentValue = + inputType === "fiat" ? formik.values.amountUsd : formik.values.amount; + const digitsLength = currentValue.replace(/[^0-9]/g, "").length; + if (digitsLength <= 6) { + return "lg"; } - if (length <= 15) { + if (digitsLength <= 10) { return "med"; } - return "small"; + if (digitsLength <= 13) { + return "small"; + } + return "xsmall"; }; - - const parsedSourceAsset = getAssetFromCanonical(formik.values.asset); const isLoading = swapAmountData.state === RequestState.IDLE || swapAmountData.state === RequestState.LOADING; - useEffect(() => { - if (cryptoInputRef.current) { - cryptoInputRef.current.focus(); - cryptoInputRef.current.select(); - } - - if (usdInputRef.current) { - usdInputRef.current.focus(); - usdInputRef.current.select(); - } - }, []); - useEffect(() => { const getData = async () => { await fetchData(); @@ -310,9 +288,11 @@ export const SwapAmount = ({ new BigNumber(availableBalance), )); - const goToEditSrcAction = () => { - goToEditSrc(); - }; + const availableBalanceText = `${displayTotal} ${srcAsset.code} ${t("available")}`; + const availableBalanceFontSizePx = AVAILABLE_BALANCE_FONT_SIZES.find( + ({ maxLen }) => availableBalanceText.length <= maxLen, + )!.sizePx; + const dstAvailableBalanceText = `${dstDisplayTotal} ${dstAsset ? dstAsset.code : ""} ${t("available")}`; return ( <> @@ -384,215 +364,151 @@ export const SwapAmount = ({
-
-
- {inputType === "crypto" && ( - <> - - {formik.values.amount || "0"} - - { - const input = e.target; - const { amount: newAmount, newCursor } = - formatAmountPreserveCursor( - e.target.value, - formik.values.amount, - getAssetDecimals( - asset, - sendData.userBalances, - isToken, - ), - e.target.selectionStart || 1, - ); - formik.setFieldValue("amount", newAmount); - dispatch(saveAmount(newAmount)); - runAfterUpdate(() => { - input.selectionStart = newCursor; - input.selectionEnd = newCursor; - }); - }} - autoFocus - autoComplete="off" - /> -
- {parsedSourceAsset.code} -
- - )} - {inputType === "fiat" && ( - <> -
- $ -
- - {formik.values.amountUsd || "0"} - - { - const input = e.target; - const { amount: newAmount, newCursor } = - formatAmountPreserveCursor( - e.target.value, - formik.values.amountUsd, - 2, - e.target.selectionStart || 1, - ); - formik.setFieldValue("amountUsd", newAmount); - dispatch(saveAmountUsd(newAmount)); - runAfterUpdate(() => { - input.selectionStart = newCursor; - input.selectionEnd = newCursor; - }); - }} - autoFocus - autoComplete="off" - /> - - )} -
-
- {supportsUsd && ( -
- {inputType === "crypto" - ? `$${priceValueUsd}` - : `${priceValue} ${parsedSourceAsset.code}`} - -
- )} -
- {isAmountTooHigh && ( - <> - - - {t("You don’t have enough {{asset}} in your account", { - asset: parsedSourceAsset.code, - })} - - - )} +
+ { + formik.setFieldValue("amount", newAmount); + dispatch(saveAmount(newAmount)); + }} + onAmountUsdChange={({ amount: newAmount }) => { + formik.setFieldValue("amountUsd", newAmount); + dispatch(saveAmountUsd(newAmount)); + }} + onToggleInputType={() => { + const newInputType = + inputType === "crypto" ? "fiat" : "crypto"; + if (newInputType === "crypto") { + dispatch(saveAmount(priceValue)); + formik.setFieldValue("amount", priceValue); + } + if (newInputType === "fiat") { + dispatch(saveAmountUsd(priceValueUsd)); + formik.setFieldValue("amountUsd", priceValueUsd); + } + setInputType(newInputType); + }} + onSelectAsset={() => { + emitMetric(METRIC_NAMES.swapPickerOpened, { + side: "source", + source: "dropdown", + }); + goToEditSrc(); + }} + />
-
-
- - +
+ {}} + onAmountUsdChange={() => {}} + onToggleInputType={() => {}} + onSelectAsset={() => { + emitMetric(METRIC_NAMES.swapPickerOpened, { + side: "destination", + source: "dropdown", + }); + goToEditDst(); + }} + /> +
diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index e1dfbda431..1bd4da5ae2 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -770,6 +770,8 @@ "You must have a buying liability of": "You must have a buying liability of", "You need XLM to create a trustline": "You need XLM to create a trustline", "You previously did not complete onboarding.": "You previously did not complete onboarding.", + "You receive": "You receive", + "You sell": "You sell", "You still have a balance of": "You still have a balance of", "You still have a buying liability of": "You still have a buying liability of", "You will have to re-add it if you want to use it again.": "You will have to re-add it if you want to use it again.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 7dd9f4b30a..d28493c074 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -768,6 +768,8 @@ "You must have a buying liability of": "Você deve ter um passivo de compra de", "You need XLM to create a trustline": "You need XLM to create a trustline", "You previously did not complete onboarding.": "Você anteriormente não completou o onboarding.", + "You receive": "You receive", + "You sell": "You sell", "You still have a balance of": "Você ainda tem um saldo de", "You still have a buying liability of": "Você ainda tem um passivo de compra de", "You will have to re-add it if you want to use it again.": "Você terá que adicioná-lo novamente se quiser usá-lo novamente.", From ff72c8c0ed37b76989fa1dfe7c6593ee0f8c66cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 19:58:24 -0300 Subject: [PATCH 029/121] [Extension] Add CTA state machine, post-scan gate & reserve pre-flight to SwapAmount Co-Authored-By: Claude Opus 4.8 --- .../__tests__/SwapAmount.ctaGate.test.tsx | 152 ++++++++++++++++++ .../SwapAmount/hooks/useSimulateSwapData.tsx | 1 + .../components/swap/SwapAmount/index.tsx | 48 +++++- 3 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx new file mode 100644 index 0000000000..d8a2027ed4 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx @@ -0,0 +1,152 @@ +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as XlmReserve from "popup/helpers/xlmReserve"; + +// Native-XLM balance that satisfies `findAssetBalance` (array-of-objects form) +// and makes `availableBalance` > 0 so the "Review swap" button is not disabled. +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices: {}, +}; + +describe("SwapAmount CTA gate", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", scanResult: null }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + }); + afterEach(() => jest.restoreAllMocks()); + + it("opens the XLM-reserve sheet instead of review when pre-flight gates", async () => { + const spyReserve = jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(true); + render( + + + , + ); + await act(async () => { + fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); + }); + expect(spyReserve).toHaveBeenCalled(); + await waitFor(() => + expect(screen.getByTestId("XlmReserveSheet")).toBeInTheDocument(), + ); + }); + + it("does NOT open the reserve sheet when shouldShowXlmReservePreflight returns false", async () => { + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(false); + render( + + + , + ); + fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); + await waitFor(() => { + expect(screen.queryByTestId("XlmReserveSheet")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 4fff6596e9..a5a928fe7e 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -81,6 +81,7 @@ export interface SimulateTxData { transactionXdr: string; dstAmountPriceUsd: string; scanResult?: BlockAidScanTxResult | null; + destMin?: string; } export const MIN_PER_OP_FEE = 100; // network minimum, stroops diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 60196d21b9..0a59858d64 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -56,6 +56,8 @@ import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { SlideupModal } from "popup/components/SlideupModal"; import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; +import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; +import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import "./styles.scss"; @@ -136,15 +138,31 @@ export const SwapAmount = ({ const [isEditingSlippage, setIsEditingSlippage] = useState(false); const [isEditingSettings, setIsEditingSettings] = useState(false); const [isReviewingTx, setIsReviewingTx] = React.useState(false); + const [isXlmReserveOpen, setIsXlmReserveOpen] = useState(false); const handleContinue = async (values: { amount: string }) => { - const amount = inputType === "crypto" ? values.amount : priceValue!; - const cleanedAmount = cleanAmount(amount); + const amountVal = inputType === "crypto" ? values.amount : priceValue!; + const cleanedAmount = cleanAmount(amountVal); dispatch(saveAmount(cleanedAmount)); await fetchSimulationData({ amount: cleanedAmount, destinationRate: dstAssetPrice, }); + const needsReserve = shouldShowXlmReservePreflight({ + requiresTrustline: + transactionData.destinationTokenDetails?.requiresTrustline ?? false, + sourceIsXlm: asset === "native", + spendableXlm: getAvailableBalance({ + assetCanonical: "native", + balances: sendData.userBalances.balances, + recommendedFee: fee, + }), + }); + if (needsReserve) { + emitMetric(METRIC_NAMES.swapXlmReserveShown); + setIsXlmReserveOpen(true); + return; + } setIsReviewingTx(true); }; @@ -355,7 +373,11 @@ export const SwapAmount = ({ formik.submitForm(); }} > - {destinationAsset ? t("Review swap") : t("Select an asset")} + {!destinationAsset + ? t("Select an asset") + : new BigNumber(cleanAmount(formik.values.amount)).isZero() + ? t("Enter an amount") + : t("Review swap")}
} @@ -576,6 +598,26 @@ export const SwapAmount = ({ amount: destinationAmount, }} title={t("You are swapping")} + destinationTokenDetails={transactionData.destinationTokenDetails} + destMin={simulationState.data?.destMin} + /> + ) : ( + <> + )} + + setIsXlmReserveOpen(false)} + isModalOpen={isXlmReserveOpen} + > + {isXlmReserveOpen ? ( + setIsXlmReserveOpen(false)} + publicKey={publicKey} + canSwapForReserve={false} + helpUrl="" + onSwapForReserve={() => { + dispatch(saveDestinationAsset("native")); + }} /> ) : ( <> From f7c230e07f32a85b6d515b728cd8b280b3adbcdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 20:18:40 -0300 Subject: [PATCH 030/121] [Extension] Wire picker selectionType, destination details & swap telemetry Co-Authored-By: Claude Opus 4.8 --- .../AmountCard/__tests__/index.test.tsx | 14 ++ .../components/amount/AmountCard/index.tsx | 1 - .../__tests__/SwapAmount.telemetry.test.tsx | 209 ++++++++++++++++ .../SwapAmount/hooks/useSimulateSwapData.tsx | 12 +- .../components/swap/SwapAmount/index.tsx | 77 ++++-- .../components/swap/SwapAmount/styles.scss | 4 + .../SwapAsset/SwapPickerSections/index.tsx | 41 +++- .../SwapAsset/__tests__/SwapAsset.test.tsx | 61 ++++- .../popup/components/swap/SwapAsset/index.tsx | 14 +- .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + extension/src/popup/views/Swap/index.tsx | 15 +- .../__tests__/Swap.selectionType.test.tsx | 228 ++++++++++++++++++ 13 files changed, 649 insertions(+), 29 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx create mode 100644 extension/src/popup/views/__tests__/Swap.selectionType.test.tsx diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index 593af6d9cc..8ebb8bef71 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -73,4 +73,18 @@ describe("AmountCard", () => { screen.getByText("You don’t have enough {{asset}} in your account"), ).toBeInTheDocument(); }); + + it("fires onSelectAsset when asset selector is clicked even with isReadOnly", () => { + const onSelectAsset = jest.fn(); + render( + + + , + ); + // The amount input must be disabled. + expect(screen.getByTestId("send-amount-amount-input")).toBeDisabled(); + // The asset-selector button must still be clickable. + fireEvent.click(screen.getByTestId("send-amount-edit-dest-asset")); + expect(onSelectAsset).toHaveBeenCalledTimes(1); + }); }); diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 1289a1c70a..6a64a00a76 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -201,7 +201,6 @@ export const AmountCard = ({ onClick={onSelectAsset} data-testid="send-amount-edit-dest-asset" aria-label={t("Change asset")} - disabled={isReadOnly} > ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), +})); + +const emitMetricMock = emitMetric as jest.Mock; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices: {}, +}; + +const renderSwapAmount = ( + transactionData: Record, + goToNext = jest.fn(), +) => + render( + + + , + ); + +describe("SwapAmount telemetry + quote-expired surfacing", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + emitMetricMock.mockClear(); + }); + + it("shows the quote-expired notice and emits swapQuoteExpired when flagged", async () => { + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.ERROR, data: null, error: "No path found" }, + isQuoteExpired: true, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + + renderSwapAmount({}); + + await waitFor(() => { + expect(screen.getByTestId("swap-quote-expired")).toBeInTheDocument(); + }); + expect( + screen.getByText( + "Quote has expired, please try again to get a new quote", + ), + ).toBeInTheDocument(); + + const expiredCall = emitMetricMock.mock.calls.find( + (c) => c[0] === "swap: quote expired", + ); + expect(expiredCall).toBeDefined(); + expect(expiredCall![1]).toMatchObject({ + sourceToken: "native", + destToken: + "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + sourceAmount: "5", + destAmount: "10", + allowedSlippage: "2", + }); + }); + + it("does NOT show the quote-expired notice when not flagged", async () => { + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", scanResult: null }, + error: null, + }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + + renderSwapAmount({}); + + await waitFor(() => { + expect( + screen.getByTestId("swap-amount-btn-continue"), + ).toBeInTheDocument(); + }); + expect(screen.queryByTestId("swap-quote-expired")).toBeNull(); + expect( + emitMetricMock.mock.calls.find((c) => c[0] === "swap: quote expired"), + ).toBeUndefined(); + }); + + it("emits swapTrustlineAdded on confirm when destination requires a trustline", async () => { + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", scanResult: null }, + error: null, + }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + + const goToNext = jest.fn(); + renderSwapAmount( + { + destinationTokenDetails: { + tokenCode: "AQUA", + requiresTrustline: true, + decimals: 7, + issuer: "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + }, + }, + goToNext, + ); + + await act(async () => { + fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); + }); + + // Confirm in the review sheet. + const confirmBtn = await screen.findByTestId("SubmitAction"); + await act(async () => { + fireEvent.click(confirmBtn); + }); + + const trustlineCall = emitMetricMock.mock.calls.find( + (c) => c[0] === "swap: trustline added", + ); + expect(trustlineCall).toBeDefined(); + expect(trustlineCall![1]).toMatchObject({ + tokenCode: "AQUA", + tokenIssuer: "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + }); + expect(goToNext).toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index a5a928fe7e..974d0e26a5 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -1,4 +1,4 @@ -import { useReducer } from "react"; +import { useReducer, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import BigNumber from "bignumber.js"; import { @@ -28,8 +28,9 @@ import { transactionDataSelector, } from "popup/ducks/transactionSubmission"; import { useScanTx } from "popup/helpers/blockaid"; -import { BlockAidScanTxResult } from "@shared/api/types"; +import { BlockAidScanTxResult, ErrorMessage } from "@shared/api/types"; import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; +import { isQuoteExpiredError } from "popup/helpers/quoteExpiry"; import { isContractId } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { AppDispatch } from "popup/App"; @@ -217,6 +218,10 @@ function useSimulateTxData({ reducer, initialState, ); + // Minimal flag the view can read: the frozen quote no longer clears (Horizon + // op_under_dest_min / op_too_few_offers). Surfaced for the quote-expired + // metric + Notification in SwapAmount. + const [isQuoteExpired, setIsQuoteExpired] = useState(false); const fetchData = async ({ amount, @@ -226,6 +231,7 @@ function useSimulateTxData({ destinationRate?: string; }) => { dispatch({ type: "FETCH_DATA_START" }); + setIsQuoteExpired(false); try { const payload = { transactionXdr: "" } as SimulateTxData; const { allowedSlippage, sourceAsset, destAsset, transactionTimeout } = @@ -305,6 +311,7 @@ function useSimulateTxData({ const { sourceAsset, destAsset } = simParams; const payload = getSwapErrorMessage(error, sourceAsset, destAsset); + setIsQuoteExpired(isQuoteExpiredError(error as ErrorMessage | undefined)); dispatch({ type: "FETCH_DATA_ERROR", payload }); return error; } @@ -313,6 +320,7 @@ function useSimulateTxData({ return { state, fetchData, + isQuoteExpired, }; } diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 0a59858d64..1fbac10732 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -119,26 +119,30 @@ export const SwapAmount = ({ }, destination, ); - const { state: simulationState, fetchData: fetchSimulationData } = - useSimulateTxData({ - publicKey, - networkDetails, - simParams: { - sourceAsset: srcAsset, - destAsset: dstAsset!, - amount, - allowedSlippage, - path, - transactionFee: fee, - transactionTimeout, - memo, - }, - }); + const { + state: simulationState, + fetchData: fetchSimulationData, + isQuoteExpired, + } = useSimulateTxData({ + publicKey, + networkDetails, + simParams: { + sourceAsset: srcAsset, + destAsset: dstAsset!, + amount, + allowedSlippage, + path, + transactionFee: fee, + transactionTimeout, + memo, + }, + }); const [isEditingSlippage, setIsEditingSlippage] = useState(false); const [isEditingSettings, setIsEditingSettings] = useState(false); const [isReviewingTx, setIsReviewingTx] = React.useState(false); const [isXlmReserveOpen, setIsXlmReserveOpen] = useState(false); + const [showQuoteExpired, setShowQuoteExpired] = useState(false); const handleContinue = async (values: { amount: string }) => { const amountVal = inputType === "crypto" ? values.amount : priceValue!; @@ -213,6 +217,26 @@ export const SwapAmount = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Quote-expired surfacing: when the simulate hook flags an expired quote + // (Horizon op_under_dest_min / op_too_few_offers), emit the metric and show + // the user-facing notification. The auto-refetch is handled by Phase E's + // getBestPath retry; this only emits + surfaces the message. + useEffect(() => { + if (!isQuoteExpired) { + setShowQuoteExpired(false); + return; + } + setShowQuoteExpired(true); + emitMetric(METRIC_NAMES.swapQuoteExpired, { + sourceToken: asset, + destToken: destinationAsset, + sourceAmount: amount, + destAmount: destinationAmount, + allowedSlippage, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isQuoteExpired]); + if (isLoading) { return ; } @@ -383,6 +407,19 @@ export const SwapAmount = ({ } >
+ {showQuoteExpired && ( +
+ +
+ )}
@@ -586,7 +623,15 @@ export const SwapAmount = ({ fee={fee} networkDetails={networkDetails} onCancel={() => setIsReviewingTx(false)} - onConfirm={goToNext} + onConfirm={() => { + if (transactionData.destinationTokenDetails?.requiresTrustline) { + emitMetric(METRIC_NAMES.swapTrustlineAdded, { + tokenCode: transactionData.destinationTokenDetails.tokenCode, + tokenIssuer: transactionData.destinationTokenDetails.issuer, + }); + } + goToNext(); + }} sendAmount={amount} sendPriceUsd={priceValueUsd} simulationState={simulationState} diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index 4ef2de839c..e5abdaa2b6 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -9,6 +9,10 @@ padding: pxToRem(16px); } + &__quote-expired { + padding: pxToRem(8px) pxToRem(16px) 0; + } + &__subtitle { text-align: center; } diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx index ed1b912158..32e1cef4e9 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx @@ -8,9 +8,30 @@ import { UnverifiedTokenInfoSheet, } from "../InfoSheets"; import type { SwapTokenRecord } from "../hooks/useSwapTokenLookup"; +import type { SwapPickerSelection } from "../index"; import "./styles.scss"; +/** Which picker section a clicked row came from (used for telemetry). */ +type PickerSource = "balances" | "popular" | "search"; + +/** + * Builds the destination descriptor passed up to the Swap view on pick. + * `decimals` is 7 for classic Stellar assets. + */ +const buildSelection = ( + r: SwapTokenRecord, + source: PickerSource, +): SwapPickerSelection => ({ + tokenCode: r.code ?? "", + requiresTrustline: r.requiresTrustline, + decimals: 7, + issuer: r.issuer || undefined, + securityLevel: r.securityLevel, + iconUrl: r.image ?? r.icon ?? undefined, + source, +}); + /** * Flat sections shape accepted by this presentational component. * Callers consuming `useSwapTokenLookup` should destructure `state.data.sections` @@ -30,7 +51,11 @@ export interface SwapPickerSectionsResult { export interface SwapPickerSectionsProps { result: SwapPickerSectionsResult; searchTerm: string; - onClickAsset: (canonical: string, isContract: boolean) => void; + onClickAsset: ( + canonical: string, + isContract: boolean, + details?: SwapPickerSelection, + ) => void; stellarExpertUrl: string; } @@ -46,7 +71,7 @@ export const SwapPickerSections = ({ const isSearching = searchTerm.trim().length > 0; - const renderRows = (records: SwapTokenRecord[]) => + const renderRows = (records: SwapTokenRecord[], source: PickerSource) => records.map((r) => ( onClickAsset(r.canonical, r.isContract)} + onClick={() => + onClickAsset(r.canonical, r.isContract, buildSelection(r, source)) + } /> )); @@ -117,7 +144,7 @@ export const SwapPickerSections = ({ > {t("Your tokens")}
- {renderRows(result.yourTokens)} + {renderRows(result.yourTokens, "balances")} )} @@ -129,7 +156,7 @@ export const SwapPickerSections = ({ > {t("Popular tokens")}
- {renderRows(result.popular)} + {renderRows(result.popular, "popular")} )} @@ -147,7 +174,7 @@ export const SwapPickerSections = ({
- {renderRows(result.verified)} + {renderRows(result.verified, "search")} )} @@ -167,7 +194,7 @@ export const SwapPickerSections = ({
- {renderRows(result.unverified)} + {renderRows(result.unverified, "search")} )} diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx index e45daa791c..05677985ee 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { Wrapper } from "popup/__testHelpers__"; import { RequestState } from "constants/request"; @@ -86,4 +86,63 @@ describe("SwapAsset selectionType", () => { expect(screen.getByTestId("swap-picker-sections")).toBeInTheDocument(); expect(screen.queryByTestId("token-list")).toBeNull(); }); + + it("destination: forwards the widened descriptor (canonical, isContract, details) on pick", () => { + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: { + sections: { + yourTokens: [], + popular: [ + { + canonical: "AQUA:G456", + code: "AQUA", + issuer: "G456", + domain: "aqua.network", + image: "icon_url", + isHeld: false, + isContract: false, + requiresTrustline: true, + }, + ], + verified: [], + unverified: [], + }, + isSearch: false, + hadSorobanMatches: false, + isFallback: false, + }, + error: null, + }, + } as any); + + const onClickAsset = jest.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-body")); + + expect(onClickAsset).toHaveBeenCalledTimes(1); + const [canonical, isContract, details] = onClickAsset.mock.calls[0]; + expect(canonical).toBe("AQUA:G456"); + expect(isContract).toBe(false); + expect(details).toMatchObject({ + tokenCode: "AQUA", + issuer: "G456", + requiresTrustline: true, + decimals: 7, + iconUrl: "icon_url", + source: "popular", + }); + }); }); diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index 594303daa1..07440e8b93 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -17,6 +17,7 @@ import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; import { getStellarExpertUrl } from "popup/helpers/account"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import type { DestinationTokenDetails } from "popup/ducks/transactionSubmission"; import { useGetSwapFromData } from "./hooks/useSwapFromData"; import { useSwapTokenLookup } from "./hooks/useSwapTokenLookup"; import { SwapPickerSections } from "./SwapPickerSections"; @@ -24,10 +25,21 @@ import type { SwapPickerSectionsResult } from "./SwapPickerSections"; import "./styles.scss"; +/** + * The destination side passes a 3rd `details` argument carrying the picked + * token's descriptor plus a `source` discriminator (which picker section the + * row came from). The source side stays 2-arg-compatible (details optional). + */ +export type SwapPickerSelection = DestinationTokenDetails & { source?: string }; + interface SwapAssetProps { selectionType: "source" | "destination"; hiddenAssets: string[]; - onClickAsset: (canonical: string, isContract: boolean) => void; + onClickAsset: ( + canonical: string, + isContract: boolean, + details?: SwapPickerSelection, + ) => void; goBack: () => void; } diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 1bd4da5ae2..5c20798b80 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -474,6 +474,7 @@ "Price": "Price", "Privacy Policy": "Privacy Policy", "Proceed with caution": "Proceed with caution", + "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", "Rate": "Rate", "Read before importing your key": "Read before importing your key", "Ready to migrate": "Ready to migrate", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index d28493c074..ce796605ce 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -474,6 +474,7 @@ "Price": "Preço", "Privacy Policy": "Política de Privacidade", "Proceed with caution": "Prossiga com cautela", + "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", "Ready to migrate": "Pronto para migrar", diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index e1f77ad3d6..f22cebfbe3 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -15,6 +15,7 @@ import { saveAmountUsd, saveAsset, saveDestinationAsset, + saveDestinationTokenDetails, saveIsToken, transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; @@ -106,9 +107,16 @@ export const Swap = () => { selectionType="destination" hiddenAssets={[transactionData.asset]} goBack={() => setActiveStep(STEPS.AMOUNT)} - onClickAsset={(canonical: string, isContract: boolean) => { + onClickAsset={(canonical, isContract, details) => { dispatch(saveDestinationAsset(canonical)); dispatch(saveIsToken(isContract)); + dispatch(saveDestinationTokenDetails(details ?? null)); + emitMetric(METRIC_NAMES.swapDestinationSelected, { + tokenCode: details?.tokenCode, + tokenIssuer: details?.issuer, + requiresTrustline: details?.requiresTrustline, + source: details?.source, + }); setActiveStep(STEPS.AMOUNT); }} /> @@ -154,6 +162,11 @@ export const Swap = () => { dispatch(saveIsToken(isContract)); dispatch(saveAmount("0")); dispatch(saveAmountUsd("0.00")); + emitMetric(METRIC_NAMES.swapSourceSelected, { + tokenCode: getAssetFromCanonical(canonical).code, + tokenIssuer: getAssetFromCanonical(canonical).issuer, + source: "balances", + }); setActiveStep(STEPS.AMOUNT); }} /> diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx new file mode 100644 index 0000000000..677c2d546f --- /dev/null +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -0,0 +1,228 @@ +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { Swap } from "popup/views/Swap"; +import { emitMetric } from "helpers/metrics"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as XlmReserve from "popup/helpers/xlmReserve"; +import * as UseSwapFromData from "popup/components/swap/SwapAsset/hooks/useSwapFromData"; +import * as UseSwapTokenLookup from "popup/components/swap/SwapAsset/hooks/useSwapTokenLookup"; + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), +})); + +const emitMetricMock = emitMetric as jest.Mock; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices: {}, +}; + +const resolvedFromState = { + state: RequestState.SUCCESS, + data: { + type: AppDataType.RESOLVED, + publicKey: "G123", + balances: { balances: [], icons: {} }, + filteredBalances: [], + networkDetails: { network: "PUBLIC", networkUrl: "" }, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + tokenPrices: {}, + }, + error: null, +}; + +const emptyLookupResult = { + sections: { yourTokens: [], popular: [], verified: [], unverified: [] }, + isSearch: false, + hadSorobanMatches: false, + isFallback: false, +}; + +const renderSwap = (transactionData: Record = {}) => + render( + + + , + ); + +describe("Swap selectionType wiring", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", scanResult: null }, + error: null, + }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(false); + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: resolvedFromState, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: emptyLookupResult, + error: null, + }, + } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + emitMetricMock.mockClear(); + }); + + it("opens the source picker (Swap from) with selectionType=source", async () => { + renderSwap(); + + // The sell card lives in the first swap card; its asset selector opens + // the SET_FROM_ASSET step. + const selectors = await screen.findAllByTestId( + "send-amount-edit-dest-asset", + ); + await act(async () => { + fireEvent.click(selectors[0]); + }); + + await waitFor(() => { + expect(screen.getByText("Swap from")).toBeInTheDocument(); + }); + // Source picker renders the held-only TokenList, not the discovery picker. + expect(screen.getByTestId("token-list")).toBeInTheDocument(); + expect(screen.queryByTestId("swap-picker-sections")).toBeNull(); + }); + + it("opens the destination picker (Swap to) when the receive card's asset selector is clicked", async () => { + renderSwap(); + + // Two AmountCard selectors render: [0] sell card (source), [1] receive card (destination). + const selectors = await screen.findAllByTestId( + "send-amount-edit-dest-asset", + ); + expect(selectors.length).toBeGreaterThanOrEqual(2); + + await act(async () => { + fireEvent.click(selectors[1]); + }); + + await waitFor(() => { + expect(screen.getByText("Swap to")).toBeInTheDocument(); + }); + }); + + it("emits swapSourceSelected with the picked source on source pick", async () => { + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: { + ...resolvedFromState, + data: { + ...resolvedFromState.data, + filteredBalances: [ + { + token: { + code: "USDC", + issuer: { + key: "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + }, + }, + total: new BigNumber("100"), + available: new BigNumber("100"), + }, + ], + }, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + + renderSwap(); + + const selectors = await screen.findAllByTestId( + "send-amount-edit-dest-asset", + ); + await act(async () => { + fireEvent.click(selectors[0]); + }); + + // The held TokenList renders a clickable USDC row. + const usdcRow = await screen.findByTestId( + "SendRow-USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + ); + await act(async () => { + fireEvent.click(usdcRow); + }); + + const sourceCall = emitMetricMock.mock.calls.find( + (c) => c[0] === "swap: source selected", + ); + expect(sourceCall).toBeDefined(); + expect(sourceCall![1]).toMatchObject({ + tokenCode: "USDC", + source: "balances", + }); + }); +}); From 7f48804bcfcafb0b5bd265873c444dcd220038b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 20:29:55 -0300 Subject: [PATCH 031/121] [Extension] Add swap-to-new-token i18n strings (en + pt) with parity test Co-Authored-By: Claude Opus 4.8 --- .../__tests__/translationParity.test.ts | 20 +++++++++++++++++++ .../src/popup/locales/pt/translation.json | 12 +++++------ 2 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 extension/src/popup/locales/__tests__/translationParity.test.ts diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts new file mode 100644 index 0000000000..861a7a96ff --- /dev/null +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -0,0 +1,20 @@ +import en from "popup/locales/en/translation.json"; +import pt from "popup/locales/pt/translation.json"; + +const swapKeys = [ + "Quote has expired, please try again to get a new quote", + "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", + "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", + "No tokens match {{term}}", + "You sell", + "You receive", +]; + +describe("swap i18n parity", () => { + it("defines every swap key in en and pt", () => { + swapKeys.forEach((k) => { + expect(en).toHaveProperty([k]); + expect(pt).toHaveProperty([k]); + }); + }); +}); diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index ce796605ce..fa03c3ec97 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -416,7 +416,7 @@ "No device detected.": "Nenhum dispositivo detectado.", "No hidden collectibles": "Nenhum colecionável oculto", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", - "No tokens match {{term}}": "No tokens match {{term}}", + "No tokens match {{term}}": "Nenhum token corresponde a {{term}}", "No transactions to show": "Nenhuma transação para mostrar", "None": "Nenhum", "Not enough lumens": "Lumens insuficientes", @@ -474,7 +474,7 @@ "Price": "Preço", "Privacy Policy": "Política de Privacidade", "Proceed with caution": "Prossiga com cautela", - "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", + "Quote has expired, please try again to get a new quote": "A cotação expirou, tente novamente para obter uma nova cotação", "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", "Ready to migrate": "Pronto para migrar", @@ -559,7 +559,7 @@ "Some destination accounts on the Stellar network require a memo to identify your payment.": "Algumas contas de destino na rede Stellar exigem um memo para identificar seu pagamento.", "Some features may be disabled at this time": "Alguns recursos podem estar desabilitados neste momento.", "Some of your assets may not appear, but they are still safe on the network!": "Alguns de seus ativos podem não aparecer, mas ainda estão seguros na rede!", - "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.": "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", + "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.": "Tokens de contrato Soroban ainda não são suportados para trocas. Tente buscar um token Classic.", "Soroban is temporarily experiencing issues": "O Soroban está temporariamente com problemas", "Soroban RPC is temporarily experiencing issues": "O Soroban RPC está temporariamente com problemas", "SOROBAN RPC URL": "URL RPC SOROBAN", @@ -659,7 +659,7 @@ "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "Para manter um novo ativo, sua conta reserva uma única vez 0,5 XLM para a linha de confiança.", "To start using this account, fund it with at least 1 XLM.": "Para começar a usar esta conta, financie-a com pelo menos 1 XLM.", "Toggle Assets": "Alternar Ativos", - "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", + "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "A descoberta de tokens está temporariamente indisponível. Você ainda pode trocar entre tokens que já possui.", "Token ID": "ID do Token", "Token ID cannot contain spaces": "ID do token não pode conter espaços", "Token ID is required": "ID do token é obrigatório", @@ -769,8 +769,8 @@ "You must have a buying liability of": "Você deve ter um passivo de compra de", "You need XLM to create a trustline": "You need XLM to create a trustline", "You previously did not complete onboarding.": "Você anteriormente não completou o onboarding.", - "You receive": "You receive", - "You sell": "You sell", + "You receive": "Você recebe", + "You sell": "Você vende", "You still have a balance of": "Você ainda tem um saldo de", "You still have a buying liability of": "Você ainda tem um passivo de compra de", "You will have to re-add it if you want to use it again.": "Você terá que adicioná-lo novamente se quiser usá-lo novamente.", From 741eaf0c7c7b3d501660b3bb099d978b00185f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 20:38:57 -0300 Subject: [PATCH 032/121] [Extension] Add swap-to-new-token Playwright E2E spec Co-Authored-By: Claude Opus 4.8 --- extension/e2e-tests/swap.test.ts | 467 +++++++++++++++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 extension/e2e-tests/swap.test.ts diff --git a/extension/e2e-tests/swap.test.ts b/extension/e2e-tests/swap.test.ts new file mode 100644 index 0000000000..ba7135f24c --- /dev/null +++ b/extension/e2e-tests/swap.test.ts @@ -0,0 +1,467 @@ +/** + * E2E spec: Swap-to-New-Token flow (Phase F, Task 11) + * + * Covers: + * 1. Held-to-held regression (smoke that the existing swap still works) + * 2. Swap-to-new-token happy path: trustline banner at review + * 3. XLM-reserve pre-flight sheet (low-XLM account) + * 4. Blockaid-flagged destination: malicious warning at review + * 5. Search: Soroban contract address: empty Soroban state + * 6. stellar.expert unreachable fallback: fallback-notice shown + * 7. Testnet: blockaid badges absent + * + * Stub URL shapes (reconciled against source): + * - Asset search: getApiStellarExpertUrl(networkDetails) + "/asset?search=" + term + * pattern: "** /asset?search**" (already in stubAllExternalApis via stubAssetSearch) + * - Popular fetch: mainnet ".../asset?sort=volume7d&order=desc&limit=50" + * testnet ".../asset?limit=50" + * patterns: "** /asset?sort=volume7d**" or "** /asset?limit=50**" + * - scan-tx: "** /scan-tx**" (registered by stubScanTx in stubAllExternalApis) + * + * testid notes (all verified against source as of this task): + * - swap-sell-card SwapAmount/index.tsx:426 + * - swap-receive-card SwapAmount/index.tsx:535 + * - send-amount-edit-dest-asset AmountCard/index.tsx:202 (shared asset-selector button) + * - swap-from-search SwapAsset/index.tsx:218 (search input for both src/dst pickers) + * - swap-amount-btn-continue SwapAmount/index.tsx:382 + * - review-tx-trustline-banner TrustlineBanner.tsx:18 + * - trustline-info-sheet TrustlineInfoSheet.tsx:16 + * - XlmReserveSheet XlmReserveSheet/index.tsx:27 + * - swap-picker-fallback-notice SwapPickerSections/index.tsx:108 + * - swap-picker-empty-soroban SwapPickerSections/index.tsx:123 + * - blockaid-malicious-label WarningMessages/index.tsx:768,933 + * + * testids NOT yet in source (follow-up: product code would need to add them): + * - "swap-receive-card-select-asset" (brief assumed this; real id is "send-amount-edit-dest-asset") + * - "swap-asset-search-input" (real id is "swap-from-search") + * - "xlm-reserve-sheet" (real id is "XlmReserveSheet") + * + * Execution: `yarn test:e2e e2e-tests/swap.test.ts` from repo root. + * This spec was NOT executed locally (Playwright E2E requires a built + * extension + browser binary not available in the sandbox). Verified + * statically for type/import correctness and fixture alignment. + */ + +import { test, expect } from "./test-fixtures"; +import { Page } from "@playwright/test"; +import { loginToTestAccount, switchToMainnet } from "./helpers/login"; +import { stubScanTxMalicious } from "./helpers/stubs"; +// Soroban contract address — searching for this should produce the Soroban empty state. +const SOROBAN_CONTRACT_ADDRESS = + "CAZXRTOKNUQ2JQQF3NCRU7GYMDJNZ2NMQN6IGN4FCT5DWPODMPVEXSND"; + +/** + * Helper: open the "Swap to" (destination) asset picker from the SwapAmount view. + * + * The receive card uses the same `send-amount-edit-dest-asset` button as the + * AmountCard shared component. Because both the sell and receive cards render + * that button, we target the one inside `swap-receive-card`. + */ +async function openSwapToPicker(page: Page) { + await page + .getByTestId("swap-receive-card") + .getByTestId("send-amount-edit-dest-asset") + .click({ force: true }); + await expect(page.getByText("Swap to")).toBeVisible({ timeout: 10000 }); +} + +// --------------------------------------------------------------------------- +// 1. Held-to-held regression +// Verifies the existing swap flow (source + destination both already held) +// still reaches the review screen. This mirrors the smoke tests that lived +// inside sendPayment.test.ts before this dedicated file existed. +// --------------------------------------------------------------------------- +test("held-to-held swap reaches review screen", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-swap").click(); + // The new SwapAmount view uses swap-sell-card (not swap-src-asset-tile) + await expect(page.getByTestId("swap-sell-card")).toBeVisible({ + timeout: 15000, + }); + + // Open the destination picker and pick a held token (XLM — always held) + await openSwapToPicker(page); + // "Your tokens" section in the destination picker lists held balances + await page.getByTestId("XLM-balance").first().click(); + + // Fill amount and proceed to review + await page.getByTestId("send-amount-amount-input").fill("1"); + await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + + // Review screen + await expect(page.getByText("You are swapping")).toBeVisible({ + timeout: 30000, + }); +}); + +// --------------------------------------------------------------------------- +// 2. Swap-to-new-token happy path +// User searches for AQUA (non-held, verified on Mainnet), picks it, and at +// the review screen a trustline banner is shown because the account has no +// AQUA trustline. Clicking the banner opens the trustline-info sheet. +// --------------------------------------------------------------------------- +test("swap to new token shows trustline banner at review", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + // Stub stellar.expert asset search to return AQUA. + // Real URL: https://api.stellar.expert/explorer/public/asset?search=AQUA + // Pattern from stubAssetSearch in stubs.ts: "**/asset?search**" + // We unroute the default (returns USDC) and install our AQUA response. + await page.unroute("**/asset?search**"); + await page.route("**/asset?search**", (route) => + route.fulfill({ + json: { + _embedded: { + records: [ + { + asset: `AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA`, + num_accounts: 50000, + num_trades: 100000, + bidding_liabilities: "1000000", + asking_liabilities: "2000000", + volume7d: 1_000_000_000_000, + }, + ], + }, + }, + }), + ); + // Also stub the popular-tokens fetch (mainnet: sort=volume7d) so the idle + // picker state has data without hitting the real stellar.expert. + await page.route("**/asset?sort=volume7d**", (route) => + route.fulfill({ + json: { + _embedded: { + records: [ + { + asset: + "AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + volume7d: 1_000_000_000_000, + domain: "aquarius.world", + }, + ], + }, + }, + }), + ); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + await page.getByTestId("nav-link-swap").click(); + await expect(page.getByTestId("swap-sell-card")).toBeVisible({ + timeout: 15000, + }); + + // Open "Swap to" picker and search for AQUA + await openSwapToPicker(page); + // The search input testid is "swap-from-search" (both src and dst pickers share it) + await page.getByTestId("swap-from-search").fill("AQUA"); + + // Pick AQUA from the search results (verified or unverified section) + await page.getByText("AQUA").first().click({ force: true }); + + // Fill amount and proceed + await page.getByTestId("send-amount-amount-input").fill("1"); + await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + + // Review screen should show trustline banner (AQUA not in account balances) + await expect(page.getByTestId("review-tx-trustline-banner")).toBeVisible({ + timeout: 30000, + }); + + // Tapping the banner opens the trustline-info sheet + await page.getByTestId("review-tx-trustline-banner").click(); + await expect(page.getByTestId("trustline-info-sheet")).toBeVisible({ + timeout: 10000, + }); +}); + +// --------------------------------------------------------------------------- +// 3. XLM-reserve pre-flight sheet +// Account has barely any XLM (0.6 total, 0.5 minimum → only 0.1 spendable). +// Picking a non-held token and attempting to swap should surface the +// XlmReserveSheet before or instead of the review screen. +// --------------------------------------------------------------------------- +test("shows XLM-reserve sheet when balance cannot cover the reserve", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + // Override default balances with a near-empty XLM account + await page.unroute("**/account-balances/**"); + await page.route("*/**/account-balances/*", (route) => + route.fulfill({ + json: { + balances: { + native: { + token: { type: "native", code: "XLM" }, + total: "0.6", + available: "0.1", + minimumBalance: "0.5", + sellingLiabilities: "0", + buyingLiabilities: "0", + blockaidData: { + result_type: "Benign", + malicious_score: "0.0", + attack_types: {}, + chain: "stellar", + address: "", + metadata: { type: "" }, + fees: {}, + features: [], + trading_limits: {}, + financial_stats: {}, + }, + }, + }, + isFunded: true, + subentryCount: 0, + error: { horizon: null, soroban: null }, + }, + }), + ); + // Stub search so AQUA appears in results + await page.unroute("**/asset?search**"); + await page.route("**/asset?search**", (route) => + route.fulfill({ + json: { + _embedded: { + records: [ + { + asset: + "AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + volume7d: 1_000_000_000_000, + }, + ], + }, + }, + }), + ); + await page.route("**/asset?sort=volume7d**", (route) => + route.fulfill({ + json: { _embedded: { records: [] } }, + }), + ); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + await page.getByTestId("nav-link-swap").click(); + await expect(page.getByTestId("swap-sell-card")).toBeVisible({ + timeout: 15000, + }); + + await openSwapToPicker(page); + await page.getByTestId("swap-from-search").fill("AQUA"); + await page.getByText("AQUA").first().click({ force: true }); + + await page.getByTestId("send-amount-amount-input").fill("0.05"); + await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + + // The XlmReserveSheet should appear because spendable XLM < required reserve + // Real testid: "XlmReserveSheet" (XlmReserveSheet/index.tsx:27) + await expect(page.getByTestId("XlmReserveSheet")).toBeVisible({ + timeout: 30000, + }); +}); + +// --------------------------------------------------------------------------- +// 4. Blockaid-flagged destination → malicious warning at review +// Stub scan-tx to return Malicious; the review screen must display the +// blockaid-malicious-label warning. +// --------------------------------------------------------------------------- +test("flagged destination surfaces blockaid malicious warning at review", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + // Override asset search to return a "scam" token + await page.unroute("**/asset?search**"); + await page.route("**/asset?search**", (route) => + route.fulfill({ + json: { + _embedded: { + records: [ + { + asset: + "SCAM-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + }, + ], + }, + }, + }), + ); + await page.route("**/asset?sort=volume7d**", (route) => + route.fulfill({ json: { _embedded: { records: [] } } }), + ); + // Override scan-tx to return Malicious + // Real URL: POST to the Freighter backend /scan-tx endpoint + // Existing helper: stubScanTxMalicious matches "**/scan-tx**" + await stubScanTxMalicious(page); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + await page.getByTestId("nav-link-swap").click(); + await expect(page.getByTestId("swap-sell-card")).toBeVisible({ + timeout: 15000, + }); + + await openSwapToPicker(page); + await page.getByTestId("swap-from-search").fill("SCAM"); + await page.getByText("SCAM").first().click({ force: true }); + + await page.getByTestId("send-amount-amount-input").fill("1"); + await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + + // Review screen: blockaid malicious label must be visible + // Real testid: "blockaid-malicious-label" (WarningMessages/index.tsx:768,933) + await expect(page.getByTestId("blockaid-malicious-label")).toBeVisible({ + timeout: 30000, + }); +}); + +// --------------------------------------------------------------------------- +// 5. Search: Soroban contract address → Soroban empty state +// Entering a contract address in the "Swap to" search should surface the +// Soroban-unsupported empty state copy. +// --------------------------------------------------------------------------- +test("search with Soroban contract address shows Soroban empty state", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + // Return empty results — the Soroban detection happens in the picker via + // hadSorobanMatches logic in SwapPickerSections based on the search term + // being a contract-shaped address; no search result records needed. + await page.unroute("**/asset?search**"); + await page.route("**/asset?search**", (route) => + route.fulfill({ json: { _embedded: { records: [] } } }), + ); + await page.route("**/asset?sort=volume7d**", (route) => + route.fulfill({ json: { _embedded: { records: [] } } }), + ); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + await page.getByTestId("nav-link-swap").click(); + await expect(page.getByTestId("swap-sell-card")).toBeVisible({ + timeout: 15000, + }); + + await openSwapToPicker(page); + // Type a Soroban contract address — SwapPickerSections.hadSorobanMatches + // triggers when the search term looks like a contract ID (56-char Stellar G/C address) + await page.getByTestId("swap-from-search").fill(SOROBAN_CONTRACT_ADDRESS); + + // The Soroban empty state message (swap-picker-empty-soroban) + // Text: "Soroban contract tokens aren't supported for swaps yet." + await expect(page.getByTestId("swap-picker-empty-soroban")).toBeVisible({ + timeout: 15000, + }); + await expect( + page.getByText(/Soroban contract tokens aren't supported/), + ).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// 6. stellar.expert unreachable → fallback notice + held-only tokens +// When the asset search AND popular-tokens fetch both fail (network abort), +// the picker falls back to showing only held tokens and displays the +// "Token discovery is temporarily unavailable" soft notice. +// --------------------------------------------------------------------------- +test("stellar.expert unreachable falls back to held-only with fallback notice", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + // Abort both the popular-tokens fetch (mainnet and testnet shapes) and any + // asset search so the lookup lands in the isFallback=true branch. + await page.unroute("**/asset?search**"); + await page.route("**/asset?search**", (route) => route.abort("failed")); + await page.route("**/asset?sort=volume7d**", (route) => + route.abort("failed"), + ); + // Testnet popular uses limit=50 without sort params + await page.route("**/asset?limit=50**", (route) => route.abort("failed")); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + await page.getByTestId("nav-link-swap").click(); + await expect(page.getByTestId("swap-sell-card")).toBeVisible({ + timeout: 15000, + }); + + await openSwapToPicker(page); + + // The fallback notice should appear automatically once popular-token fetch fails + // Real testid: "swap-picker-fallback-notice" (SwapPickerSections/index.tsx:108) + await expect(page.getByTestId("swap-picker-fallback-notice")).toBeVisible({ + timeout: 20000, + }); + await expect( + page.getByText(/Token discovery is temporarily unavailable/), + ).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// 7. Testnet: blockaid badges absent +// On Testnet (the default network after loginToTestAccount) the picker +// should not show any ScamAssetIcon / blockaid warning labels. +// --------------------------------------------------------------------------- +test("testnet swap picker shows no blockaid scam icons", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + // Default network from loginToTestAccount is Testnet — no switchToMainnet call + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-swap").click(); + await expect(page.getByTestId("swap-sell-card")).toBeVisible({ + timeout: 15000, + }); + + await openSwapToPicker(page); + + // On Testnet there should be no Blockaid ScamAssetIcon rendered in the picker + await expect(page.locator('[data-testid="ScamAssetIcon"]')).toHaveCount(0, { + timeout: 10000, + }); + // No malicious label either + await expect( + page.locator('[data-testid="blockaid-malicious-label"]'), + ).toHaveCount(0); +}); From aa07004b405bcc547781d2f6413b11d6e8cb2a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 24 Jun 2026 20:52:37 -0300 Subject: [PATCH 033/121] [Extension] Reset swap amount input to crypto for priceless source asset Add a useEffect guard (mirroring the SendAmount pattern) that forces inputType back to "crypto" whenever the source asset has no USD price, preventing a render crash via priceValue! dereferences. Also replaces priceValue! with priceValue ?? "0" as belt-and-suspenders protection for the transient render before the effect fires. Co-Authored-By: Claude Opus 4.8 --- .../SwapAmount.pricelessAsset.test.tsx | 137 ++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 24 ++- 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.pricelessAsset.test.tsx diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.pricelessAsset.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.pricelessAsset.test.tsx new file mode 100644 index 0000000000..b0174887a4 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.pricelessAsset.test.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; + +// Swap data where the source asset (native/XLM) has NO tokenPrices entry, +// simulating a priceless asset being selected as the source. +const swapDataNoPrices = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [] }, + tokenPrices: {}, +}; + +describe("SwapAmount priceless source asset", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + isQuoteExpired: false, + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: swapDataNoPrices, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + }); + afterEach(() => jest.restoreAllMocks()); + + it("does NOT crash when rendered in fiat mode with a priceless source asset", async () => { + const setInputType = jest.fn(); + + // Rendering with inputType="fiat" while the source asset has no price is + // the crash scenario: priceValue is null and was previously dereferenced as + // priceValue! inside isAmountTooHigh (and validate / handleContinue). + expect(() => + render( + + + , + ), + ).not.toThrow(); + + // The useEffect guard should reset inputType back to "crypto" because + // the source asset has no USD price. + await waitFor(() => { + expect(setInputType).toHaveBeenCalledWith("crypto"); + }); + }); + + it("renders the sell card without crashing when source has no price", () => { + // Belt-and-suspenders: the component must at least mount and show the sell + // card (render gate passed) even before the useEffect fires. + render( + + + , + ); + + expect(screen.getByTestId("swap-sell-card")).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 1fbac10732..03963ac83e 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -145,7 +145,8 @@ export const SwapAmount = ({ const [showQuoteExpired, setShowQuoteExpired] = useState(false); const handleContinue = async (values: { amount: string }) => { - const amountVal = inputType === "crypto" ? values.amount : priceValue!; + const amountVal = + inputType === "crypto" ? values.amount : (priceValue ?? "0"); const cleanedAmount = cleanAmount(amountVal); dispatch(saveAmount(cleanedAmount)); await fetchSimulationData({ @@ -171,7 +172,7 @@ export const SwapAmount = ({ }; const validate = (values: { amount: string }) => { - const amount = inputType === "crypto" ? values.amount : priceValue!; + const amount = inputType === "crypto" ? values.amount : (priceValue ?? "0"); const val = cleanAmount(amount); if (val.indexOf(".") !== -1 && val.split(".")[1].length > 7) { return { amount: AMOUNT_ERROR.DEC_MAX }; @@ -217,6 +218,23 @@ export const SwapAmount = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // If the user was in fiat mode and the current source asset no longer has a + // USD price (e.g. after a direction-swap or source picker change), force back + // to crypto mode so priceValue-dependent expressions are always safe. + useEffect(() => { + if ( + inputType === "fiat" && + swapAmountData.state === RequestState.SUCCESS && + swapAmountData.data?.type === AppDataType.RESOLVED + ) { + const currentAssetPrice = + swapAmountData.data.tokenPrices?.[asset]?.currentPrice; + if (!currentAssetPrice) { + setInputType("crypto"); + } + } + }, [inputType, swapAmountData.state, swapAmountData.data, asset]); + // Quote-expired surfacing: when the simulate hook flags an expired quote // (Horizon op_under_dest_min / op_too_few_offers), emit the metric and show // the user-facing notification. The auto-refetch is handled by Phase E's @@ -326,7 +344,7 @@ export const SwapAmount = ({ new BigNumber(availableBalance), )) || (inputType === "fiat" && - new BigNumber(cleanAmount(priceValue!)).gt( + new BigNumber(cleanAmount(priceValue ?? "0")).gt( new BigNumber(availableBalance), )); From 50c3ee6a7a216af3f4e960c44be56f9c5a540683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 12:55:30 -0300 Subject: [PATCH 034/121] [Extension] Fix wrong asset on "Sent!" screen after switching token mid-send (#2871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Send is opened from a token's detail page, the URL carries ?asset= for the entire (linearized) flow. useSendQueryParams' effect re-dispatched saveAsset() on every run, and its deps include the collections cache — which refreshes on a successful submit. So switching to a different token mid-flow was silently reverted to the URL token exactly at the "Sending..." -> "Sent!" transition, and the success screen (which reads transactionData.asset) showed the wrong token. 100% reproducible. The existing currentAssetRef guard only covered the no-param / invalid-param branches, not the valid-param branch that does the reverting. Gate the destination/asset URL pre-population on an actual location.search change via a lastAppliedSearchRef, so re-runs from other dependencies no longer clobber an asset/destination the user changed mid-flow. The collectible block still re-runs (it depends on collections loading asynchronously). Adds a regression test reproducing the revert. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/useSendQueryParams.test.tsx | 58 ++++++++++++++++++- .../views/Send/hooks/useSendQueryParams.ts | 22 ++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/views/Send/hooks/__tests__/useSendQueryParams.test.tsx b/extension/src/popup/views/Send/hooks/__tests__/useSendQueryParams.test.tsx index 94801ce793..39735885c0 100644 --- a/extension/src/popup/views/Send/hooks/__tests__/useSendQueryParams.test.tsx +++ b/extension/src/popup/views/Send/hooks/__tests__/useSendQueryParams.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Provider } from "react-redux"; import { useLocation } from "react-router-dom"; -import { renderHook } from "@testing-library/react"; +import { renderHook, act } from "@testing-library/react"; import { StrKey } from "stellar-sdk"; import { makeDummyStore, @@ -22,6 +22,7 @@ import { saveFederationAddress, } from "popup/ducks/transactionSubmission"; import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { saveCollections } from "popup/ducks/cache"; import * as StellarHelpers from "@shared/helpers/stellar"; import * as SorobanHelpers from "@shared/api/helpers/soroban"; @@ -615,4 +616,59 @@ describe("useSendQueryParams", () => { ); }); }); + + describe("In-flow asset selection (issue #2871)", () => { + // Repro: open a token's detail page -> Send (URL carries ?asset= + // for the whole flow) -> switch to a different token -> submit. The success + // ("Sent!") screen reads transactionData.asset, so the user's switched asset + // must survive any effect re-run that happens after the switch (e.g. the + // account/collections refresh triggered by a successful submit). + const switchedAsset = + "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + + it("does not revert the user's switched asset when the effect re-runs without a URL change", () => { + mockUseLocation.mockReturnValue({ + pathname: "/send", + search: `?asset=${validAsset}`, + state: null, + }); + + const store = makeDummyStore(defaultState); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + renderHook(() => useSendQueryParams(), { wrapper: Wrapper }); + + // Mount pre-populates the asset from the URL param. + expect(store.getState().transactionSubmission.transactionData.asset).toBe( + validAsset, + ); + + // The user switches the source asset mid-flow (SendDestinationAsset). + act(() => { + store.dispatch(saveAsset(switchedAsset)); + }); + expect(store.getState().transactionSubmission.transactionData.asset).toBe( + switchedAsset, + ); + + // An unrelated store change re-triggers the hook's effect without any URL + // change (mirrors the account/collections refresh after a successful send, + // which changes the collections dependency reference). + act(() => { + store.dispatch( + saveCollections({ + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: TEST_PUBLIC_KEY, + collections: [], + }), + ); + }); + + // The switched asset must survive — the URL param must not clobber it. + expect(store.getState().transactionSubmission.transactionData.asset).toBe( + switchedAsset, + ); + }); + }); }); diff --git a/extension/src/popup/views/Send/hooks/useSendQueryParams.ts b/extension/src/popup/views/Send/hooks/useSendQueryParams.ts index 24600420e8..46ba0a08c7 100644 --- a/extension/src/popup/views/Send/hooks/useSendQueryParams.ts +++ b/extension/src/popup/views/Send/hooks/useSendQueryParams.ts @@ -51,12 +51,18 @@ export function useSendQueryParams() { const networkDetails = useSelector(settingsNetworkDetailsSelector); const { transactionData } = useSelector(transactionSubmissionSelector); - // Read transactionData.asset via a ref so the hook reacts only to URL - // changes — not to subsequent in-flow asset picks (which would otherwise - // re-dispatch the URL param and revert the user's selection). + // currentAssetRef lets the param handlers below read the latest selected + // asset without putting transactionData.asset in the effect deps (which would + // re-run the effect on every asset change). const currentAssetRef = useRef(transactionData.asset); currentAssetRef.current = transactionData.asset; + // Tracks the last location.search the effect actually pre-populated from, so + // re-runs triggered by other dependencies (e.g. the collections cache + // refreshing after a successful send) don't re-apply the URL params and + // revert an asset/destination the user changed mid-flow. (Fixes #2871.) + const lastAppliedSearchRef = useRef(null); + useEffect(() => { const params = new URLSearchParams(location.search); const destinationParam = params.get("destination"); @@ -88,6 +94,16 @@ export function useSendQueryParams() { } } + // Only pre-populate destination/asset from the URL when location.search + // itself changes (initial mount or a new deep link). Re-runs caused by + // other dependencies must not re-apply the params and clobber what the user + // picked mid-flow — the collectible block above still re-runs because it + // depends on collections loading asynchronously. (Fixes #2871.) + if (lastAppliedSearchRef.current === location.search) { + return; + } + lastAppliedSearchRef.current = location.search; + // Pre-populate destination if provided and valid if (destinationParam) { const isValidDestination = From 90363c14be9375eacab6ca71b4b7505b88ac6571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 14:04:08 -0300 Subject: [PATCH 035/121] Polish Swap home + destination picker to match Figma - You receive card: drop the "available" balance label and show the fiat ($) value line; suppress the input-type toggle on the read-only card. - Calculate the received amount live as the user types, via a debounced path-only quote (the full simulation still runs at review). Includes an out-of-order request guard, error reset, fiat-precision rounding, and a freeze while the review sheet is open. - Reorder the home to sell -> direction chevron -> receive -> percentage buttons; use a single centered Icon.ChevronDown. - Destination picker: load held balances so "Your tokens" populates, render section headers sentence-case ("Popular tokens"), and exclude the source asset so a token can't be swapped to itself. Co-Authored-By: Claude Opus 4.8 --- .../AmountCard/__tests__/index.test.tsx | 24 ++ .../components/amount/AmountCard/index.tsx | 29 ++- .../__tests__/SwapAmount.ctaGate.test.tsx | 4 +- .../__tests__/SwapAmount.layout.test.tsx | 24 ++ .../__tests__/SwapAmount.liveQuote.test.tsx | 132 +++++++++++ .../components/swap/SwapAmount/index.tsx | 224 ++++++++++++++---- .../components/swap/SwapAmount/styles.scss | 26 ++ .../__tests__/SwapPickerSections.test.tsx | 21 ++ .../SwapAsset/SwapPickerSections/index.tsx | 36 +-- .../SwapAsset/SwapPickerSections/styles.scss | 4 +- .../SwapAsset/__tests__/SwapAsset.test.tsx | 44 ++++ .../popup/components/swap/SwapAsset/index.tsx | 52 ++-- 12 files changed, 517 insertions(+), 103 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index 8ebb8bef71..f915c9ce62 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -74,6 +74,30 @@ describe("AmountCard", () => { ).toBeInTheDocument(); }); + it("shows the fiat line but no input-type toggle when read-only", () => { + render( + + + , + ); + expect(screen.getByText("$1.23")).toBeInTheDocument(); + expect(screen.queryByTestId("amount-fiat-toggle")).toBeNull(); + }); + + it("shows the input-type toggle when not read-only and USD is supported", () => { + render( + + + , + ); + expect(screen.getByTestId("amount-fiat-toggle")).toBeInTheDocument(); + }); + it("fires onSelectAsset when asset selector is clicked even with isReadOnly", () => { const onSelectAsset = jest.fn(); render( diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 6a64a00a76..a84d252b45 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -218,18 +218,23 @@ export const AmountCard = ({
{fiatLineText} - + {/* Read-only cards (e.g. the swap "You receive" card) show the + fiat value but cannot toggle input type, so omit the toggle. */} + {!isReadOnly && ( + + )}
)} diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx index d8a2027ed4..a35d0740db 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx @@ -17,8 +17,8 @@ import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/use import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as XlmReserve from "popup/helpers/xlmReserve"; -// Native-XLM balance that satisfies `findAssetBalance` (array-of-objects form) -// and makes `availableBalance` > 0 so the "Review swap" button is not disabled. +// Native-XLM balance that makes `availableBalance` > 0 so the "Review swap" +// button is not disabled. const nativeBalance = { token: { type: "native", code: "XLM" }, total: new BigNumber("100"), diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx index bdd7822e01..8f0b31363a 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx @@ -53,4 +53,28 @@ describe("SwapAmount layout", () => { expect(screen.getByTestId("swap-direction-chevron")).toBeInTheDocument(); expect(screen.getByTestId("swap-percentage-buttons")).toBeInTheDocument(); }); + + it("orders sell card, chevron, receive card, then percentage buttons", () => { + render( + + + , + ); + const sell = screen.getByTestId("swap-sell-card"); + const chevron = screen.getByTestId("swap-direction-chevron"); + const receive = screen.getByTestId("swap-receive-card"); + const pct = screen.getByTestId("swap-percentage-buttons"); + + const following = Node.DOCUMENT_POSITION_FOLLOWING; + expect(sell.compareDocumentPosition(chevron) & following).toBeTruthy(); + expect(chevron.compareDocumentPosition(receive) & following).toBeTruthy(); + expect(receive.compareDocumentPosition(pct) & following).toBeTruthy(); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx new file mode 100644 index 0000000000..fc2946c235 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { render, act } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices: {}, +}; + +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + +const renderSwapAmount = (transactionData: Record) => + render( + + + , + ); + +describe("SwapAmount live receive-amount quote", () => { + let getBestPath: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + // The review-time simulation hook is still mounted; keep it inert. + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + getBestPath = jest + .spyOn(HorizonGetBestPath, "horizonGetBestPath") + .mockResolvedValue({ destination_amount: "42", path: [] } as any); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("runs a debounced path-only quote when a source amount and destination are set", async () => { + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + // Debounced: nothing fires synchronously. + expect(getBestPath).not.toHaveBeenCalled(); + + await act(async () => { + jest.advanceTimersByTime(600); + }); + + // Lightweight path lookup (not the full simulation) with the typed amount. + expect(getBestPath).toHaveBeenCalledWith( + expect.objectContaining({ + amount: "5", + sourceAsset: "native", + destAsset: AQUA, + }), + ); + }); + + it("does not quote when there is no destination asset", async () => { + renderSwapAmount({ amount: "5", destinationAsset: "" }); + + await act(async () => { + jest.advanceTimersByTime(600); + }); + + expect(getBestPath).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 03963ac83e..7828e68d45 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Navigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Form, Field, FieldProps, Formik, useFormik } from "formik"; +import { debounce } from "lodash"; import BigNumber from "bignumber.js"; import { object as YupObject, number as YupNumber } from "yup"; import { @@ -22,6 +23,7 @@ import { saveAmountUsd, saveAsset, saveDestinationAsset, + saveSwapBestPath, saveTransactionFee, saveTransactionTimeout, transactionDataSelector, @@ -34,14 +36,17 @@ import { } from "popup/helpers/formatters"; import { TX_SEND_MAX } from "popup/constants/transaction"; import { useGetSwapAmountData } from "./hooks/useGetSwapAmountData"; -import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; +import { + getAssetFromCanonical, + getCanonicalFromAsset, + isMainnet, +} from "helpers/stellar"; import { RequestState } from "constants/request"; import { Loading } from "popup/components/Loading"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { openTab } from "popup/helpers/navigate"; import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; -import { findAssetBalance } from "popup/helpers/balance"; import { getAssetDecimals, getAvailableBalance } from "popup/helpers/soroban"; import { AppDispatch } from "popup/App"; import { emitMetric } from "helpers/metrics"; @@ -57,6 +62,7 @@ import { SlideupModal } from "popup/components/SlideupModal"; import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; +import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import "./styles.scss"; @@ -255,6 +261,124 @@ export const SwapAmount = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isQuoteExpired]); + // Live quote: debounce the source amount and fetch the best path so the + // "You receive" amount updates as the user types. This is a lightweight + // path-only lookup (no XDR build / Blockaid scan / quote-expiry surfacing) — + // the full simulation runs at review time in handleContinue. A monotonic + // request id discards out-of-order responses; failures reset the displayed + // amount to 0 so a stale quote never lingers. + const liveQuoteReqRef = useRef(0); + const liveQuoteArgsRef = useRef({ asset, destinationAsset, networkDetails }); + liveQuoteArgsRef.current = { asset, destinationAsset, networkDetails }; + const destinationAmountRef = useRef(destinationAmount); + destinationAmountRef.current = destinationAmount; + // Once the review sheet is open the quote is frozen — a late live quote must + // not overwrite (or reset) the amount being reviewed. + const isReviewingRef = useRef(isReviewingTx); + isReviewingRef.current = isReviewingTx; + + const debouncedQuote = useMemo( + () => + debounce((quoteAmount: string) => { + const reqId = ++liveQuoteReqRef.current; + const { + asset: src, + destinationAsset: dst, + networkDetails: net, + } = liveQuoteArgsRef.current; + (async () => { + try { + const bestPath = await horizonGetBestPath({ + amount: quoteAmount, + sourceAsset: src, + destAsset: dst, + networkDetails: net, + }); + if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { + return; // superseded by a newer quote, or frozen for review + } + if (!bestPath?.destination_amount) { + dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); + return; + } + const path: string[] = []; + bestPath.path.forEach((p) => { + if (!p.asset_code && !p.asset_issuer) { + path.push(p.asset_type); + } else { + path.push(getCanonicalFromAsset(p.asset_code, p.asset_issuer)); + } + }); + dispatch( + saveSwapBestPath({ + path, + destinationAmount: bestPath.destination_amount, + }), + ); + } catch { + if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { + return; + } + // No path / network error: clear the stale received amount. + dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); + } + })(); + }, 500), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + useEffect(() => () => debouncedQuote.cancel(), [debouncedQuote]); + + useEffect(() => { + if ( + swapAmountData.state !== RequestState.SUCCESS || + swapAmountData.data?.type !== AppDataType.RESOLVED || + !destinationAsset + ) { + return; + } + const livePrices = swapAmountData.data.tokenPrices; + const liveSrcPrice = livePrices[asset]?.currentPrice; + const liveDecimals = getAssetDecimals( + asset, + swapAmountData.data.userBalances, + isToken, + ); + const cryptoAmount = + inputType === "fiat" + ? liveSrcPrice + ? new BigNumber(cleanAmount(amountUsd || "0")) + .dividedBy(new BigNumber(liveSrcPrice)) + .decimalPlaces(liveDecimals) + .toString() + : "0" + : cleanAmount(amount || "0"); + + if (new BigNumber(cryptoAmount || "0").isGreaterThan(0)) { + debouncedQuote(cryptoAmount); + } else { + // Source amount cleared: cancel any pending/in-flight quote and reset the + // received amount so the card shows 0 (skip the dispatch if already 0). + debouncedQuote.cancel(); + liveQuoteReqRef.current += 1; + if ( + destinationAmountRef.current !== "0" && + destinationAmountRef.current !== "" + ) { + dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + amount, + amountUsd, + asset, + destinationAsset, + inputType, + swapAmountData.state, + ]); + if (isLoading) { return ; } @@ -297,9 +421,6 @@ export const SwapAmount = ({ const sendData = data; const assetIcon = sendData.icons[asset]; const dstAssetIcon = sendData.icons[destinationAsset]; - const dstAssetBalance = dstAsset - ? findAssetBalance(sendData.userBalances.balances, dstAsset) - : null; const prices = sendData.tokenPrices; const assetPrice = prices[asset] && prices[asset].currentPrice; const xlmPrice = prices["native"]?.currentPrice; @@ -328,16 +449,22 @@ export const SwapAmount = ({ )}` : null; const supportsUsd = isMainnet(data.networkDetails) && assetPrice; + const dstSupportsUsd = isMainnet(data.networkDetails) && dstAssetPrice; + const dstPriceValueUsd = dstAssetPrice + ? formatAmount( + roundUsdValue( + new BigNumber(dstAssetPrice) + .multipliedBy(new BigNumber(cleanAmount(destinationAmount || "0"))) + .toString(), + ), + ) + : null; const availableBalance = getAvailableBalance({ assetCanonical: asset, balances: sendData.userBalances.balances, recommendedFee: fee, }); const displayTotal = `${formatAmount(availableBalance)}`; - const dstDisplayTotal = - dstAssetBalance && dstAsset - ? `${formatAmount(dstAssetBalance.total.toString())}` - : "0"; const isAmountTooHigh = (inputType === "crypto" && new BigNumber(cleanAmount(formik.values.amount)).gt( @@ -352,7 +479,6 @@ export const SwapAmount = ({ const availableBalanceFontSizePx = AVAILABLE_BALANCE_FONT_SIZES.find( ({ maxLen }) => availableBalanceText.length <= maxLen, )!.sizePx; - const dstAvailableBalanceText = `${dstDisplayTotal} ${dstAsset ? dstAsset.code : ""} ${t("available")}`; return ( <> @@ -494,40 +620,6 @@ export const SwapAmount = ({ }} />
-
- { - emitMetric(METRIC_NAMES.swapAmount); - const fraction = new BigNumber(pct).dividedBy(100); - if (inputType === "fiat" && assetPrice) { - const pctUsd = formatAmount( - roundUsdValue( - new BigNumber(assetPrice) - .multipliedBy( - new BigNumber(cleanAmount(availableBalance)), - ) - .multipliedBy(fraction) - .toString(), - ), - ); - formik.setFieldValue("amountUsd", pctUsd); - dispatch(saveAmountUsd(pctUsd)); - } else { - const pctAmount = new BigNumber( - cleanAmount(availableBalance), - ) - .multipliedBy(fraction) - .decimalPlaces(assetDecimals) - .toString(); - formik.setFieldValue("amount", pctAmount); - dispatch(saveAmount(pctAmount)); - } - }} - /> -
- +
+
+ { + emitMetric(METRIC_NAMES.swapAmount); + const fraction = new BigNumber(pct).dividedBy(100); + if (inputType === "fiat" && assetPrice) { + const pctUsd = formatAmount( + roundUsdValue( + new BigNumber(assetPrice) + .multipliedBy( + new BigNumber(cleanAmount(availableBalance)), + ) + .multipliedBy(fraction) + .toString(), + ), + ); + formik.setFieldValue("amountUsd", pctUsd); + dispatch(saveAmountUsd(pctUsd)); + } else { + const pctAmount = new BigNumber( + cleanAmount(availableBalance), + ) + .multipliedBy(fraction) + .decimalPlaces(assetDecimals) + .toString(); + formik.setFieldValue("amount", pctAmount); + dispatch(saveAmount(pctAmount)); + } + }} + /> +
diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index e5abdaa2b6..8138c4d1b9 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -29,6 +29,32 @@ } } + &__cards { + width: 100%; + } + + // The direction toggle sits centered on the seam between the sell and + // receive cards, overlapping the small gap between them. + &__direction { + display: flex; + justify-content: center; + align-items: center; + position: relative; + z-index: 1; + margin: pxToRem(-10px) 0; + + .Button { + width: pxToRem(32px); + height: pxToRem(32px); + min-width: pxToRem(32px); + padding: 0; + } + } + + &__percentage-buttons { + margin-top: pxToRem(16px); + } + &__simplebar { margin-top: 1rem; diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index 7a02cc064e..f7e4fb79ce 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -150,6 +150,27 @@ describe("SwapPickerSections", () => { ).toBeInTheDocument(); }); + it("excludes hiddenAssets (the swap source) from every section", () => { + render( + , + ); + + // The hidden source asset (XLM) is filtered out of all sections... + expect(screen.queryByTestId("row-XLM")).toBeNull(); + // ...while other held/popular tokens remain. + expect(screen.getByTestId("row-USDC")).toBeInTheDocument(); + expect(screen.getByTestId("row-AQUA")).toBeInTheDocument(); + }); + it("idle + new account + no popular: renders nothing (no generic empty state)", () => { render( { @@ -71,6 +75,13 @@ export const SwapPickerSections = ({ const isSearching = searchTerm.trim().length > 0; + // Exclude hidden canonicals (the swap source asset) from every section. + const hidden = new Set(hiddenAssets); + const yourTokens = result.yourTokens.filter((r) => !hidden.has(r.canonical)); + const popular = result.popular.filter((r) => !hidden.has(r.canonical)); + const verified = result.verified.filter((r) => !hidden.has(r.canonical)); + const unverified = result.unverified.filter((r) => !hidden.has(r.canonical)); + const renderRows = (records: SwapTokenRecord[], source: PickerSource) => records.map((r) => ( - 0 - : (result.isNewAccount ? 0 : result.yourTokens.length) + - result.popular.length > - 0; + ? yourTokens.length + verified.length + unverified.length > 0 + : (result.isNewAccount ? 0 : yourTokens.length) + popular.length > 0; return (
@@ -136,7 +142,7 @@ export const SwapPickerSections = ({ ) : null ) : ( <> - {!result.isNewAccount && result.yourTokens.length > 0 && ( + {!result.isNewAccount && yourTokens.length > 0 && ( <>
{t("Your tokens")}
- {renderRows(result.yourTokens, "balances")} + {renderRows(yourTokens, "balances")} )} - {!isSearching && result.popular.length > 0 && ( + {!isSearching && popular.length > 0 && ( <>
{t("Popular tokens")}
- {renderRows(result.popular, "popular")} + {renderRows(popular, "popular")} )} - {isSearching && result.verified.length > 0 && ( + {isSearching && verified.length > 0 && ( <>
{t("Verified")} @@ -174,11 +180,11 @@ export const SwapPickerSections = ({
- {renderRows(result.verified, "search")} + {renderRows(verified, "search")} )} - {isSearching && result.unverified.length > 0 && ( + {isSearching && unverified.length > 0 && ( <>
@@ -194,7 +200,7 @@ export const SwapPickerSections = ({
- {renderRows(result.unverified, "search")} + {renderRows(unverified, "search")} )} diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss index 5c75aeeb92..7f80b1fbb4 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss @@ -13,9 +13,7 @@ margin-top: 1rem; margin-bottom: 0.25rem; color: var(--sds-clr-gray-11); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.04em; + font-size: 0.875rem; &__info { background: transparent; diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx index 05677985ee..10a1d30f18 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -145,4 +145,48 @@ describe("SwapAsset selectionType", () => { source: "popular", }); }); + + it("destination: runs the idle lookup with the account's held balances", () => { + const heldUsdc = { + token: { code: "USDC", issuer: { key: "GUSD" } }, + total: "10", + }; + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: { + ...resolvedFromState, + data: { + ...resolvedFromState.data, + balances: { balances: [heldUsdc], icons: {} }, + }, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + const lookupFetchData = jest.fn().mockResolvedValue(undefined); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: lookupFetchData, + state: { + state: RequestState.SUCCESS, + data: emptyLookupResult, + error: null, + }, + } as any); + + render( + + + , + ); + + // The held balances (not an empty array) must reach the token lookup so the + // "Your tokens" section can be populated. + expect(lookupFetchData).toHaveBeenCalledWith( + expect.objectContaining({ searchTerm: "", balances: [heldUsdc] }), + ); + }); }); diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index 07440e8b93..f6af3df94c 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -110,32 +110,39 @@ export const SwapAsset = ({ debouncedSubmit(); }; + // Always load the account's held balances. The source list renders them + // directly; the destination picker feeds them into the token lookup so the + // "Your tokens" section can be populated. + useEffect(() => { + const getData = async () => { + await fetchData(true); + }; + getData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Destination picker: once the held balances resolve, run the idle lookup + // (held tokens + popular). Skipped while searching, since the debounced + // search submit drives lookupFetchData in that case. useEffect(() => { if (!isDestination) { - const getData = async () => { - await fetchData(true); - }; - getData(); - } else { - // Trigger initial idle fetch (populate held tokens + popular) - const resolvedFrom = fromState.data; - const balances = - resolvedFrom?.type === AppDataType.RESOLVED - ? resolvedFrom.balances.balances - : []; - const publicKey = - resolvedFrom?.type === AppDataType.RESOLVED - ? resolvedFrom.publicKey - : ""; - lookupFetchData({ - searchTerm: "", - balances, - publicKey, - networkDetails, - }); + return; + } + const resolvedFrom = fromState.data; + if (resolvedFrom?.type !== AppDataType.RESOLVED) { + return; + } + if (formik.values.searchTerm.trim().length > 0) { + return; } + lookupFetchData({ + searchTerm: "", + balances: resolvedFrom.balances.balances, + publicKey: resolvedFrom.publicKey, + networkDetails, + }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isDestination, fromState.data]); // Source-only rerouting/onboarding guard if (!isDestination) { @@ -227,6 +234,7 @@ export const SwapAsset = ({ From cc6d33ed9db5fac0d87a63bdd95bbcb592f33ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 15:12:15 -0300 Subject: [PATCH 036/121] Refine Swap home + picker details to match Figma - Receive card follows the sell card's fiat/crypto mode and always shows the fiat ($) line (so "$0.00" is visible in the initial state). - Receive picker shows a "+ Select" affordance before a token is chosen. - Direction toggle: 40px circular button (gray-03, no border, 16px chevron) centered on an 8px seam, overlapping the cards (matches Figma). - Tighten the receive card -> percentage buttons gap to 12px. - Smaller "Fee:" font size (14px). - "Your tokens" rows now show real token icons (thread the account icons map through the lookup into held records). Co-Authored-By: Claude Opus 4.8 --- .../AmountCard/__tests__/index.test.tsx | 13 +++++ .../components/amount/AmountCard/index.tsx | 35 +++++++++----- .../components/amount/AmountCard/styles.scss | 7 +++ .../components/swap/SwapAmount/index.tsx | 24 ++++++---- .../components/swap/SwapAmount/styles.scss | 48 ++++++++++++++----- .../__tests__/useSwapTokenLookup.test.ts | 11 +++++ .../SwapAsset/hooks/useSwapTokenLookup.ts | 17 ++++++- .../popup/components/swap/SwapAsset/index.tsx | 6 +++ .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + 10 files changed, 130 insertions(+), 35 deletions(-) diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index f915c9ce62..1f66326444 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -98,6 +98,19 @@ describe("AmountCard", () => { expect(screen.getByTestId("amount-fiat-toggle")).toBeInTheDocument(); }); + it("renders a '+ Select' affordance (no asset code) and still fires onSelectAsset", () => { + const onSelectAsset = jest.fn(); + render( + + + , + ); + expect(screen.getByText("Select")).toBeInTheDocument(); + expect(screen.queryByText("XLM")).toBeNull(); + fireEvent.click(screen.getByTestId("send-amount-edit-dest-asset")); + expect(onSelectAsset).toHaveBeenCalledTimes(1); + }); + it("fires onSelectAsset when asset selector is clicked even with isReadOnly", () => { const onSelectAsset = jest.fn(); render( diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index a84d252b45..3b3f3092c2 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -197,20 +197,33 @@ export const AmountCard = ({
diff --git a/extension/src/popup/components/amount/AmountCard/styles.scss b/extension/src/popup/components/amount/AmountCard/styles.scss index be0d52e7a9..3c85210805 100644 --- a/extension/src/popup/components/amount/AmountCard/styles.scss +++ b/extension/src/popup/components/amount/AmountCard/styles.scss @@ -125,6 +125,13 @@ width: pxToRem(14px); height: pxToRem(14px); } + + // Empty "+ Select" state (e.g. the swap receive card before a token is + // picked): show a larger leading plus icon instead of the asset logo. + &--empty svg:last-child { + width: pxToRem(20px); + height: pxToRem(20px); + } } &__asset-code { diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 7828e68d45..28495ec67f 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -449,7 +449,6 @@ export const SwapAmount = ({ )}` : null; const supportsUsd = isMainnet(data.networkDetails) && assetPrice; - const dstSupportsUsd = isMainnet(data.networkDetails) && dstAssetPrice; const dstPriceValueUsd = dstAssetPrice ? formatAmount( roundUsdValue( @@ -624,11 +623,10 @@ export const SwapAmount = ({ className="SwapAsset__direction" data-testid="swap-direction-chevron" > - +
{ }); expect(result.sections.popular.map((r) => r.code)).toEqual(["USDC"]); }); + + it("attaches held-token icons from the icons map (keyed by canonical)", () => { + const result = buildSwapSections({ + searchTerm: "", + balances: [heldAqua], + networkDetails: MAINNET, + icons: { "AQUA:GBNZ": "https://icons/aqua.png" }, + }); + expect(result.sections.yourTokens[0].code).toBe("AQUA"); + expect(result.sections.yourTokens[0].image).toBe("https://icons/aqua.png"); + }); }); describe("buildSwapSections — search term", () => { diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 729b749c9c..386fa5ac8b 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -78,7 +78,10 @@ export const EMPTY_RESULT: SwapTokenLookupResult = { * Converts a held balance entry (AssetType) into a SwapTokenRecord. * Returns null for LiquidityPoolShareAsset entries (no code/issuer). */ -const heldToRecord = (balance: AssetType): SwapTokenRecord | null => { +const heldToRecord = ( + balance: AssetType, + icons: Record = {}, +): SwapTokenRecord | null => { if (!("token" in balance) || !balance.token) { return null; } @@ -97,6 +100,9 @@ const heldToRecord = (balance: AssetType): SwapTokenRecord | null => { issuer, domain: null, canonical, + // Held tokens carry no icon URL of their own — pull it from the account's + // icons map (keyed by canonical) so "Your tokens" rows show real logos. + image: icons[canonical] || undefined, isHeld: true, isContract: false, requiresTrustline: false, @@ -142,6 +148,7 @@ export const buildSwapSections = ({ unverifiedAssets = [], searchResults = [], isFallback = false, + icons = {}, }: { searchTerm: string; balances: AssetType[]; @@ -151,12 +158,13 @@ export const buildSwapSections = ({ unverifiedAssets?: ManageAssetCurrency[]; searchResults?: ManageAssetCurrency[]; isFallback?: boolean; + icons?: Record; }): SwapTokenLookupResult => { const term = searchTerm.trim().toLowerCase(); const isSearch = term.length > 0; const heldRecords = balances - .map(heldToRecord) + .map((b) => heldToRecord(b, icons)) .filter((r): r is SwapTokenRecord => r !== null); const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); @@ -361,11 +369,13 @@ export const useSwapTokenLookup = () => { balances, publicKey: _publicKey, networkDetails, + icons = {}, }: { searchTerm: string; balances: AssetType[]; publicKey: string; networkDetails: NetworkDetails; + icons?: Record; }): Promise => { // Cancel any in-flight request abortControllerRef.current?.abort(); @@ -387,6 +397,7 @@ export const useSwapTokenLookup = () => { searchTerm, balances, networkDetails, + icons, isFallback: true, }), }); @@ -469,6 +480,7 @@ export const useSwapTokenLookup = () => { searchTerm, balances, networkDetails, + icons, popular, verifiedAssets, unverifiedAssets, @@ -537,6 +549,7 @@ export const useSwapTokenLookup = () => { searchTerm, balances, networkDetails, + icons, isFallback: true, }), }); diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index f6af3df94c..f5dc8c46d7 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -82,11 +82,16 @@ export const SwapAsset = ({ resolvedFrom?.type === AppDataType.RESOLVED ? resolvedFrom.publicKey : ""; + const icons = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.balances.icons + : {}; lookupFetchData({ searchTerm: values.searchTerm, balances, publicKey, networkDetails, + icons, }); } else { filterBalances(values.searchTerm); @@ -140,6 +145,7 @@ export const SwapAsset = ({ balances: resolvedFrom.balances.balances, publicKey: resolvedFrom.publicKey, networkDetails, + icons: resolvedFrom.balances.icons, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDestination, fromState.data]); diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 5c20798b80..a7e7ce26ee 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -511,6 +511,7 @@ "Search issuer public key, classic assets, SAC assets, and TI assets": "Search issuer public key, classic assets, SAC assets, and TI assets", "Search token name or address": "Search token name or address", "Security": "Security", + "Select": "Select", "Select a hardware wallet you’d like to use with Freighter.": "Select a hardware wallet you’d like to use with Freighter.", "Select an asset": "Select an asset", "Select asset": "Select asset", @@ -585,6 +586,7 @@ "Swap": "Swap", "Swap destination": "Swap destination", "Swap destination token logo": "Swap destination token logo", + "Swap direction": "Swap direction", "Swap failed": "Swap failed", "Swap for 0.5 XLM": "Swap for 0.5 XLM", "Swap from": "Swap from", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index fa03c3ec97..730808b18f 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -511,6 +511,7 @@ "Search issuer public key, classic assets, SAC assets, and TI assets": "Buscar chave pública do issuer, assets clássicos, assets SAC e assets TI", "Search token name or address": "Buscar nome do token ou endereço", "Security": "Segurança", + "Select": "Selecionar", "Select a hardware wallet you’d like to use with Freighter.": "Selecione uma carteira de hardware que você gostaria de usar com o Freighter.", "Select an asset": "Selecionar um ativo", "Select asset": "Selecionar ativo", @@ -585,6 +586,7 @@ "Swap": "Trocar", "Swap destination": "Destino da troca", "Swap destination token logo": "Logotipo do token de destino da troca", + "Swap direction": "Swap direction", "Swap failed": "Troca falhou", "Swap for 0.5 XLM": "Swap for 0.5 XLM", "Swap from": "Trocar de", From c0846e6903e1a1c2b116717e9dd373e50a02218c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 15:56:15 -0300 Subject: [PATCH 037/121] Conform Swap changes to AGENTS.md best practices - Extract the live-quote debounce interval into LIVE_QUOTE_DEBOUNCE_MS (code-style: no magic numbers). - Add WHY explanations to the exhaustive-deps eslint-disables introduced for the live-quote and destination-picker effects (anti-patterns/performance). - Translate the "Swap direction" aria-label to pt. Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/swap/SwapAmount/index.tsx | 9 ++++++--- extension/src/popup/components/swap/SwapAsset/index.tsx | 4 ++-- extension/src/popup/locales/pt/translation.json | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 28495ec67f..c50dfcd0fc 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -69,6 +69,9 @@ import "./styles.scss"; const defaultSlippage = "2"; +// Debounce window for the live "You receive" quote while the user is typing. +const LIVE_QUOTE_DEBOUNCE_MS = 500; + const AVAILABLE_BALANCE_FONT_SIZES = [ { maxLen: 28, sizePx: 14 }, { maxLen: 42, sizePx: 12 }, @@ -323,8 +326,8 @@ export const SwapAmount = ({ dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); } })(); - }, 500), - // eslint-disable-next-line react-hooks/exhaustive-deps + }, LIVE_QUOTE_DEBOUNCE_MS), + // eslint-disable-next-line react-hooks/exhaustive-deps -- created once; reads the latest asset/destination/network via liveQuoteArgsRef so it stays stable across renders [], ); @@ -369,7 +372,7 @@ export const SwapAmount = ({ dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); } } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- debouncedQuote/dispatch are stable and destinationAmount is read via a ref, so quote results don't re-trigger this effect (which would loop) }, [ amount, amountUsd, diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index f5dc8c46d7..c33a1a50e8 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -123,7 +123,7 @@ export const SwapAsset = ({ await fetchData(true); }; getData(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only balance fetch; fetchData is stable for this purpose }, []); // Destination picker: once the held balances resolve, run the idle lookup @@ -147,7 +147,7 @@ export const SwapAsset = ({ networkDetails, icons: resolvedFrom.balances.icons, }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-run only when held balances resolve; lookupFetchData/networkDetails/searchTerm are intentionally excluded (search is driven by the debounced submit) }, [isDestination, fromState.data]); // Source-only rerouting/onboarding guard diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 730808b18f..3f5286bc7b 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -586,7 +586,7 @@ "Swap": "Trocar", "Swap destination": "Destino da troca", "Swap destination token logo": "Logotipo do token de destino da troca", - "Swap direction": "Swap direction", + "Swap direction": "Direção da troca", "Swap failed": "Troca falhou", "Swap for 0.5 XLM": "Swap for 0.5 XLM", "Swap from": "Trocar de", From 202964341cdcad286ee84775c0061ff652ace2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:02:54 -0300 Subject: [PATCH 038/121] Add setInputType to the priceless-asset effect deps setInputType is the parent's useState setter (stable identity), so listing it satisfies react-hooks/exhaustive-deps without risk of extra runs. Clears the last build warning in SwapAmount. Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/swap/SwapAmount/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index c50dfcd0fc..746eeba213 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -242,7 +242,13 @@ export const SwapAmount = ({ setInputType("crypto"); } } - }, [inputType, swapAmountData.state, swapAmountData.data, asset]); + }, [ + inputType, + swapAmountData.state, + swapAmountData.data, + asset, + setInputType, + ]); // Quote-expired surfacing: when the simulate hook flags an expired quote // (Horizon op_under_dest_min / op_too_few_offers), emit the metric and show From 910a746e834a58e717b1d9824c877af45f343051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:07:27 -0300 Subject: [PATCH 039/121] Add performancePlugin no-op to the Amplitude autocapture stub @amplitude/analytics-browser@2.44.x added a `performancePlugin` import from @amplitude/plugin-autocapture-browser, which we alias to a build-time stub to strip remotely-hosted code (Chrome Web Store / AMO policy). The stub didn't export performancePlugin, producing a webpack "export not found" warning. Mirror the real package's export with a never-invoked no-op (autocapture stays off). Co-Authored-By: Claude Opus 4.8 --- extension/webpack/amplitude-autocapture-stub.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extension/webpack/amplitude-autocapture-stub.js b/extension/webpack/amplitude-autocapture-stub.js index 5826bf55e9..d99d73f6a2 100644 --- a/extension/webpack/amplitude-autocapture-stub.js +++ b/extension/webpack/amplitude-autocapture-stub.js @@ -46,6 +46,9 @@ export const autocapturePlugin = noopPlugin( export const frustrationPlugin = noopPlugin( "@amplitude/plugin-frustration-browser", ); +export const performancePlugin = noopPlugin( + "@amplitude/plugin-autocapture-browser/performance", +); // Mirror the real package's named exports so any importer resolves cleanly. export const plugin = autocapturePlugin; From 4dc95c13d925c1258347c53641e29542861a08fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:18:25 -0300 Subject: [PATCH 040/121] Mock runtime.onMessage/connect in the dev webextension-polyfill shim The localhost:9000 dev server swaps webextension-polyfill for a shim that only mocked tabs.create. SessionLockListener (runtime.onMessage) and SidebarSigningListener (runtime.connect) register listeners on mount, so with no runtime on the shim they threw and the error boundary crashed the dev app. Loaded as an unpacked extension the real runtime exists, so only the dev server was affected. Add no-op runtime.onMessage/onMessageExternal/connect/sendMessage so the UI renders for dev iteration. Co-Authored-By: Claude Opus 4.8 --- config/shims/webextension-polyfill.ts | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/config/shims/webextension-polyfill.ts b/config/shims/webextension-polyfill.ts index e248852f21..252f28751b 100644 --- a/config/shims/webextension-polyfill.ts +++ b/config/shims/webextension-polyfill.ts @@ -1,5 +1,42 @@ +/** + * Dev-only stub for `webextension-polyfill`, swapped in by webpack.dev.js + * (NormalModuleReplacementPlugin). The popup served at localhost:9000 runs as a + * plain web page with no extension runtime, so `browser.*` APIs the UI touches + * at mount must resolve to no-ops — otherwise listeners registered in effects + * (e.g. SessionLockListener's `runtime.onMessage`, SidebarSigningListener's + * `runtime.connect`) throw and the error boundary takes down the whole app. + * + * This only makes the UI render for fast iteration — real messaging requires + * loading the unpacked extension. + */ + +const noopEvent = { + addListener: () => undefined, + removeListener: () => undefined, + hasListener: () => false, +}; + +const makePort = (name = "") => ({ + name, + onMessage: noopEvent, + onDisconnect: noopEvent, + postMessage: () => undefined, + disconnect: () => undefined, +}); + export default { tabs: { create: ({ url }: { url: string }) => window.open(url), }, + runtime: { + onMessage: noopEvent, + onMessageExternal: noopEvent, + connect: ({ name }: { name?: string } = {}) => makePort(name), + sendMessage: () => + Promise.reject( + new Error( + "webextension-polyfill dev shim: runtime messaging is unavailable at localhost:9000 — load the unpacked extension for full functionality", + ), + ), + }, }; From 8736023bc4e2b9927555dcfc471bd9ec5796c626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:44:34 -0300 Subject: [PATCH 041/121] Use a plain plus icon for the empty swap Select picker Swap into-token picker placeholder used Icon.PlusCircle; switch to Icon.Plus to match the freighter-mobile style. Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/amount/AmountCard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 3b3f3092c2..7810cf39bb 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -220,7 +220,7 @@ export const AmountCard = ({ ) : ( <> - + {t("Select")} )} From bfb42f1fb8d19dbd1c8adcd070247560ba4d05cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:45:20 -0300 Subject: [PATCH 042/121] Match the empty swap Select pill dimensions to the token picker Give the leading plus icon the same 20px + 4px-margin footprint as the asset logo so the "Select" placeholder is the same height/size as the regular token picker pill. Co-Authored-By: Claude Opus 4.8 --- .../src/popup/components/amount/AmountCard/styles.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/amount/AmountCard/styles.scss b/extension/src/popup/components/amount/AmountCard/styles.scss index 3c85210805..4570a183d0 100644 --- a/extension/src/popup/components/amount/AmountCard/styles.scss +++ b/extension/src/popup/components/amount/AmountCard/styles.scss @@ -127,10 +127,12 @@ } // Empty "+ Select" state (e.g. the swap receive card before a token is - // picked): show a larger leading plus icon instead of the asset logo. - &--empty svg:last-child { + // picked): give the leading plus the same footprint (20px + 4px margin) as + // the asset logo so the pill matches the regular token picker's dimensions. + &--empty svg:first-child { width: pxToRem(20px); height: pxToRem(20px); + margin: pxToRem(4px); } } From 0868e930c915bdb74a6ec210e1f5a9ea30211bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:46:01 -0300 Subject: [PATCH 043/121] Add the dropdown chevron to the empty swap Select picker The regular token picker shows a trailing chevron-down; mirror it on the "Select" placeholder so the affordance reads as a dropdown. Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/amount/AmountCard/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 7810cf39bb..12ce497b8d 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -222,6 +222,7 @@ export const AmountCard = ({ <> {t("Select")} + )} From 156ac7513b4245dcb768b16d2d92ad8d8df3ecc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:46:48 -0300 Subject: [PATCH 044/121] Darken the swap direction toggle so it's visible on the card seam The toggle background matched the cards (gray-03), making it blend in. Use gray-01 (a step darker, as the token picker pill does) so it stands out. Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/swap/SwapAmount/styles.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index 55e0eba0f0..4e2dce79f9 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -60,7 +60,9 @@ padding: 0; border: none; border-radius: pxToRem(100px); - background-color: var(--sds-clr-gray-03); + // A step darker than the cards (gray-03) so the toggle stays visible on the + // seam between them. + background-color: var(--sds-clr-gray-01); color: var(--sds-clr-gray-12); cursor: pointer; @@ -70,7 +72,7 @@ } &:hover { - background-color: var(--sds-clr-gray-04); + background-color: var(--sds-clr-gray-03); } } From ab092cdf7cbd7c6196274bd1f546e3cf22f8fea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 16:47:30 -0300 Subject: [PATCH 045/121] Enlarge the swap confirm button to match the Send flow Swap's bottom CTA used size="md"; the Send flow uses size="lg". Match it so the confirm buttons are consistent across flows. Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/swap/SwapAmount/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 746eeba213..d14f4072c7 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -530,7 +530,7 @@ export const SwapAmount = ({
- {renderRows(verified, "search")} + {renderDiscoverRows(verified, "search")} )} @@ -200,7 +239,7 @@ export const SwapPickerSections = ({ - {renderRows(unverified, "search")} + {renderDiscoverRows(unverified, "search")} )} diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/index.tsx new file mode 100644 index 0000000000..cf6f1babc8 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/index.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import { Icon } from "@stellar/design-system"; + +import { openTab } from "popup/helpers/navigate"; + +import "./styles.scss"; + +interface SwapTokenMenuProps { + code: string; + issuerKey?: string; + stellarExpertUrl: string; +} + +/** + * The "…" overflow menu shown on the right of a non-held token row in the Swap + * destination picker: copy the issuer address, or view the asset on + * stellar.expert. + */ +export const SwapTokenMenu = ({ + code, + issuerKey = "", + stellarExpertUrl, +}: SwapTokenMenuProps) => { + const { t } = useTranslation(); + + const copyAddress = async () => { + if (!issuerKey) { + return; + } + await navigator.clipboard.writeText(issuerKey); + }; + + const viewOnExpert = () => { + openTab(`${stellarExpertUrl}/asset/${code}-${issuerKey}`); + }; + + return ( + + + + + + + + + + + + ); +}; diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/styles.scss b/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/styles.scss new file mode 100644 index 0000000000..a29b3d412e --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/styles.scss @@ -0,0 +1,27 @@ +.SwapTokenMenu { + background: transparent; + border: none; + cursor: pointer; + color: var(--sds-clr-gray-11); + padding: 0.25rem; + + &__content { + display: flex; + flex-direction: column; + background: var(--sds-clr-gray-03); + border: 1px solid var(--sds-clr-gray-06); + border-radius: 0.5rem; + padding: 0.25rem; + z-index: 10; + } + + &__item { + background: transparent; + border: none; + text-align: left; + padding: 0.5rem 0.75rem; + cursor: pointer; + color: var(--sds-clr-gray-12); + white-space: nowrap; + } +} From 07d3b622af268586d65dbb079b4975f36e33ae40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 18:26:33 -0300 Subject: [PATCH 053/121] Migrate Add-a-token rows to the shared AssetListRow Render the Add-a-token list through AssetListRow (with the existing ManageAssetRowButton as the right slot) instead of the bespoke ManageAssetRow, so Add-a-token and the Swap picker share one row component. Adds a `displayCode` prop to AssetListRow for the SAC name-vs-code case, makes the row full-width, and removes the now-dead ManageAssetRow + its unused imports. Legacy testids (ManageAssetCode/ManageAssetDomain) are preserved. Co-Authored-By: Claude Opus 4.8 --- .../popup/components/AssetListRow/index.tsx | 9 +- .../popup/components/AssetListRow/styles.scss | 1 + .../manageAssets/ManageAssetRows/index.tsx | 118 +++++------------- 3 files changed, 41 insertions(+), 87 deletions(-) diff --git a/extension/src/popup/components/AssetListRow/index.tsx b/extension/src/popup/components/AssetListRow/index.tsx index f65653276b..50d09e724d 100644 --- a/extension/src/popup/components/AssetListRow/index.tsx +++ b/extension/src/popup/components/AssetListRow/index.tsx @@ -8,6 +8,9 @@ import "./styles.scss"; export interface AssetListRowProps { code: string; + /** Label to show instead of `code` (e.g. a SAC token's name). Falls back to + * `code`. `code`/`issuer` are still used for the icon/canonical. */ + displayCode?: string; issuer?: string; domain?: string | null; /** Icon URL (TOML image / stellar.expert). */ @@ -32,6 +35,7 @@ export interface AssetListRowProps { */ export const AssetListRow = ({ code, + displayCode, issuer = "", domain, iconUrl, @@ -45,7 +49,8 @@ export const AssetListRow = ({ }: AssetListRowProps) => { const canonical = code === "XLM" && !issuer ? "native" : getCanonicalFromAsset(code, issuer); - const displayCode = code.length > 20 ? truncateString(code) : code; + const label = displayCode ?? code; + const displayLabel = label.length > 20 ? truncateString(label) : label; return (
@@ -64,7 +69,7 @@ export const AssetListRow = ({ />
- {displayCode} + {displayLabel}
{domain ? (
diff --git a/extension/src/popup/components/AssetListRow/styles.scss b/extension/src/popup/components/AssetListRow/styles.scss index 87bfcbe5f1..1d0a09a836 100644 --- a/extension/src/popup/components/AssetListRow/styles.scss +++ b/extension/src/popup/components/AssetListRow/styles.scss @@ -5,6 +5,7 @@ align-items: center; justify-content: space-between; gap: pxToRem(8px); + width: 100%; &__body { display: flex; diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx index 444977dcb0..b3fc3278a8 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx @@ -3,19 +3,15 @@ import { createPortal } from "react-dom"; import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; -import { - formatDomain, - getCanonicalFromAsset, - truncateString, -} from "helpers/stellar"; +import { getCanonicalFromAsset } from "helpers/stellar"; import { isContractId, isAssetSac } from "popup/helpers/soroban"; import { findAssetBalance } from "popup/helpers/balance"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; -import { AssetIcon } from "popup/components/account/AccountAssets"; import { InfoTooltip } from "popup/basics/InfoTooltip"; import { AccountBalances } from "helpers/hooks/useGetBalances"; import { SlideupModal } from "popup/components/SlideupModal"; import { publicKeySelector } from "popup/ducks/accountServices"; +import { AssetListRow } from "popup/components/AssetListRow"; import { ChangeTrustInternal } from "./ChangeTrustInternal"; import { ManageAssetRowButton } from "../ManageAssetRowButton"; import { ToggleTokenInternal } from "./ToggleTokenInternal"; @@ -116,34 +112,37 @@ export const ManageAssetRows = ({ isTrustlineActive, name, }) => ( - <> - - { - setSelectedAsset({ - code, - issuer, - domain, - name, - image, - isTrustlineActive, - contract, - }); - }} - /> - + { + setSelectedAsset({ + code, + issuer, + domain, + name, + image, + isTrustlineActive, + contract, + }); + }} + /> + } + /> )} />
@@ -436,54 +435,3 @@ const AssetRows = ({ ); }; - -export const ManageAssetRow = ({ - code = "", - issuer = "", - image = "", - domain, - name, - isSuspicious = false, - contractId, -}: AssetRowData) => { - const networkDetails = useSelector(settingsNetworkDetailsSelector); - const canonicalAsset = getCanonicalFromAsset(code, issuer); - // use the name unless the name is SAC, format "code:issuer" - const assetCode = - name && - contractId && - !isAssetSac({ - asset: { - code, - issuer, - contract: contractId, - }, - networkDetails, - }) - ? name - : code; - const truncatedAssetCode = - assetCode.length > 20 ? truncateString(assetCode) : assetCode; - - return ( - <> - -
-
- {truncatedAssetCode} -
-
- {formatDomain(domain || "")} -
-
- - ); -}; From ec0ca42ad5a53c5cf90360cf007311b247be094b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 19:34:32 -0300 Subject: [PATCH 054/121] Space the Swap discover rows so they aren't glued together Popular/Verified/Unverified rows (AssetListRow) had no vertical padding once moved off SwapTokenRow. Give them 0.75rem top/bottom (scoped to the picker) for ~1.5rem between rows, matching the held rows and the Add-a-token list. Co-Authored-By: Claude Opus 4.8 --- .../swap/SwapAsset/SwapPickerSections/styles.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss index 7f80b1fbb4..a54eba2233 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss @@ -2,6 +2,13 @@ display: flex; flex-direction: column; + // Give discover rows (AssetListRow) the same vertical rhythm as the held + // rows and the Add-a-token list (~1.5rem between adjacent rows) so they + // aren't glued together. + .AssetListRow { + padding: 0.75rem 0; + } + &__notice { margin-bottom: 0.75rem; } From 78a1018b46b13d52cf7312a1ee273224b0d6ef94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 19:44:40 -0300 Subject: [PATCH 055/121] Use a shared BalanceRow for Swap "Your tokens"; retire SwapTokenRow Add a reusable BalanceRow (icon + code + token balance | fiat + 24h delta) and render the picker's held "Your tokens" through it, so it shows balance + fiat + % change like the account-home list. Thread the account's token prices into the swap token lookup so held records carry tokenAmount/fiatValue/percentChange24h (mirrors the home row's computation). SwapTokenRow is now unused and removed; the discover rows already moved to AssetListRow. Co-Authored-By: Claude Opus 4.8 --- .../src/popup/components/BalanceRow/index.tsx | 97 +++++++++++++ .../popup/components/BalanceRow/styles.scss | 62 ++++++++ .../__tests__/SwapPickerSections.test.tsx | 9 +- .../SwapAsset/SwapPickerSections/index.tsx | 48 ++++--- .../__tests__/SwapTokenRow.test.tsx | 114 --------------- .../swap/SwapAsset/SwapTokenRow/index.tsx | 135 ------------------ .../swap/SwapAsset/SwapTokenRow/styles.scss | 71 --------- .../SwapAsset/hooks/useSwapTokenLookup.ts | 45 +++++- .../popup/components/swap/SwapAsset/index.tsx | 6 + 9 files changed, 238 insertions(+), 349 deletions(-) create mode 100644 extension/src/popup/components/BalanceRow/index.tsx create mode 100644 extension/src/popup/components/BalanceRow/styles.scss delete mode 100644 extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx delete mode 100644 extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx delete mode 100644 extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss diff --git a/extension/src/popup/components/BalanceRow/index.tsx b/extension/src/popup/components/BalanceRow/index.tsx new file mode 100644 index 0000000000..d71eaec67f --- /dev/null +++ b/extension/src/popup/components/BalanceRow/index.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import BigNumber from "bignumber.js"; + +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { AssetIcons } from "@shared/api/types"; +import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; +import { getPriceDeltaColor } from "popup/helpers/balance"; + +import "./styles.scss"; + +export interface BalanceRowProps { + code: string; + issuerKey?: string; + assetIcons?: AssetIcons; + /** Direct icon URL (used when there is no assetIcons map entry). */ + iconUrl?: string | null; + isSuspicious?: boolean; + isLPShare?: boolean; + /** Formatted token balance, e.g. "123.45". */ + amount: string; + /** Formatted fiat balance incl. symbol, e.g. "$12.34". Null → "--". */ + fiatAmount?: string | null; + /** Raw 24h % change number string (e.g. "1.23"); drives color + display. + * Null → "--". */ + percentChange?: string | null; + onClick?: () => void; + "data-testid"?: string; + amountTestId?: string; + fiatTestId?: string; + deltaTestId?: string; +} + +/** + * Shared held-asset row: icon + token code + token balance on the left, fiat + * balance + 24h % delta on the right. Used by the account-home balances list + * and the Swap destination picker's "Your tokens" section. + */ +export const BalanceRow = ({ + code, + issuerKey, + assetIcons = {}, + iconUrl, + isSuspicious = false, + isLPShare = false, + amount, + fiatAmount, + percentChange, + onClick, + "data-testid": dataTestId, + amountTestId, + fiatTestId, + deltaTestId, +}: BalanceRowProps) => { + const hasDelta = percentChange !== undefined && percentChange !== null; + const deltaColor = hasDelta + ? getPriceDeltaColor(new BigNumber(roundUsdValue(percentChange as string))) + : ""; + + return ( +
+
+ +
+ {code} +
+ {amount} +
+
+
+
+
+ {fiatAmount ?? "--"} +
+
+ {hasDelta + ? `${formatAmount(roundUsdValue(percentChange as string))}%` + : "--"} +
+
+
+ ); +}; diff --git a/extension/src/popup/components/BalanceRow/styles.scss b/extension/src/popup/components/BalanceRow/styles.scss new file mode 100644 index 0000000000..c05620b199 --- /dev/null +++ b/extension/src/popup/components/BalanceRow/styles.scss @@ -0,0 +1,62 @@ +.BalanceRow { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--sds-clr-gray-12); + font-size: 1rem; + line-height: 1.5rem; + + &__left { + min-width: 0; + display: flex; + align-items: center; + font-weight: var(--font-weight-medium); + } + + &__value { + min-width: 0; + display: flex; + flex-direction: column; + } + + &__code { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + &__amount { + font-size: var(--sds-fs-secondary); + color: var(--sds-clr-gray-11); + } + + &__right { + min-width: 0; + font-weight: var(--font-weight-regular); + text-align: right; + } + + &__fiat { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + &__delta { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + font-size: var(--sds-fs-secondary); + + &.positive { + color: var(--sds-clr-green-09); + } + + &.negative { + color: var(--sds-clr-gray-11); + } + } +} diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index aaebb8ad40..ba6e308ce4 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -12,14 +12,15 @@ jest.mock("react-i18next", () => ({ // SwapTokenRow is unit-tested separately; stub it to a simple marker so these // tests assert section structure, not row internals. -jest.mock("../../SwapTokenRow", () => ({ - SwapTokenRow: ({ code }: { code: string }) => ( +// "Your tokens" rows render through the shared BalanceRow; discover rows +// (Popular/Verified/Unverified) render through AssetListRow. Stub both to the +// same marker so section-structure assertions hold. +jest.mock("popup/components/BalanceRow", () => ({ + BalanceRow: ({ code }: { code: string }) => (
), })); -// Discover rows (Popular/Verified/Unverified) render through the shared -// AssetListRow; stub it to the same marker so section-structure assertions hold. jest.mock("popup/components/AssetListRow", () => ({ AssetListRow: ({ code }: { code: string }) => (
diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx index 1ac013a645..1e83f34cd8 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Icon, Notification } from "@stellar/design-system"; -import { SwapTokenRow } from "../SwapTokenRow"; import { SwapTokenMenu } from "../SwapTokenMenu"; +import { BalanceRow } from "popup/components/BalanceRow"; import { VerifiedTokenInfoSheet, UnverifiedTokenInfoSheet, @@ -85,26 +85,30 @@ export const SwapPickerSections = ({ const verified = result.verified.filter((r) => !hidden.has(r.canonical)); const unverified = result.unverified.filter((r) => !hidden.has(r.canonical)); - // Held "Your tokens" rows still use SwapTokenRow (shows balance/value). - const renderRows = (records: SwapTokenRecord[], source: PickerSource) => - records.map((r) => ( - - onClickAsset(r.canonical, r.isContract, buildSelection(r, source)) - } - /> - )); + // Held "Your tokens" rows use the shared BalanceRow (code + balance + fiat + + // 24h delta), matching the account-home balances list. + const renderBalanceRows = ( + records: SwapTokenRecord[], + source: PickerSource, + ) => + records.map((r) => { + const code = r.code ?? ""; + return ( + + onClickAsset(r.canonical, r.isContract, buildSelection(r, source)) + } + /> + ); + }); // Non-held discover rows (Popular / Verified / Unverified) use the shared // AssetListRow with an overflow menu on the right. @@ -189,7 +193,7 @@ export const SwapPickerSections = ({ > {t("Your tokens")}
- {renderRows(yourTokens, "balances")} + {renderBalanceRows(yourTokens, "balances")} )} diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx deleted file mode 100644 index f6405be4db..0000000000 --- a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/__tests__/SwapTokenRow.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; - -import { SecurityLevel } from "popup/constants/blockaid"; -import { SwapTokenRow } from "../index"; - -jest.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -jest.mock("popup/components/account/AccountAssets", () => ({ - AssetIcon: () =>
, -})); - -const mockOpenTab = jest.fn(); -jest.mock("popup/helpers/navigate", () => ({ - openTab: (...args: unknown[]) => mockOpenTab(...args), -})); - -const AQUA_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; - -const baseProps = { - code: "AQUA", - issuerKey: AQUA_ISSUER, - domain: "aqua.network", - iconUrl: "", - onClick: jest.fn(), - stellarExpertUrl: "https://stellar.expert/explorer/public", -}; - -describe("SwapTokenRow", () => { - afterEach(() => jest.clearAllMocks()); - - it("held row shows fiat value and 24h change, no context menu", () => { - render( - , - ); - - expect(screen.getByTestId("SwapTokenRow-AQUA-fiat")).toHaveTextContent( - "$1,234.56", - ); - expect(screen.getByTestId("SwapTokenRow-AQUA-change")).toHaveTextContent( - "+1.23%", - ); - expect(screen.queryByTestId("SwapTokenRow-AQUA-menu")).toBeNull(); - }); - - it("non-held row shows context menu and no fiat value", () => { - render(); - - expect(screen.queryByTestId("SwapTokenRow-AQUA-fiat")).toBeNull(); - expect(screen.getByTestId("SwapTokenRow-AQUA-menu")).toBeInTheDocument(); - }); - - it("renders the ScamAssetIcon badge when malicious in non-held variant", () => { - render( - , - ); - - expect(screen.getByTestId("ScamAssetIcon")).toBeInTheDocument(); - }); - - it("does not render the ScamAssetIcon badge when held, even if malicious", () => { - render( - , - ); - - expect(screen.queryByTestId("ScamAssetIcon")).toBeNull(); - }); - - it("does not render the badge when safe", () => { - render( - , - ); - - expect(screen.queryByTestId("ScamAssetIcon")).toBeNull(); - }); - - it("calls onClick when the row body is clicked", () => { - const onClick = jest.fn(); - render(); - - fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-body")); - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it("View on stellar.expert opens the asset page in a new tab", () => { - render(); - - fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-menu")); - fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-view-expert")); - - expect(mockOpenTab).toHaveBeenCalledWith( - `https://stellar.expert/explorer/public/asset/AQUA-${AQUA_ISSUER}`, - ); - }); -}); diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx deleted file mode 100644 index 58e2b2ee44..0000000000 --- a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/index.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import * as Popover from "@radix-ui/react-popover"; -import { Icon } from "@stellar/design-system"; - -import { AssetIcon } from "popup/components/account/AccountAssets"; -import { ScamAssetIcon } from "popup/components/account/ScamAssetIcon"; -import { SecurityLevel } from "popup/constants/blockaid"; -import { openTab } from "popup/helpers/navigate"; -import { formatDomain } from "helpers/stellar"; - -import "./styles.scss"; - -export interface SwapTokenRowProps { - code: string; - issuerKey?: string; - domain?: string | null; - iconUrl?: string; - isHeld: boolean; - fiatValue?: string; - percentChange24h?: string; - securityLevel?: SecurityLevel; - onClick: () => void; - stellarExpertUrl: string; -} - -export const SwapTokenRow = ({ - code, - issuerKey = "", - domain, - iconUrl = "", - isHeld, - fiatValue, - percentChange24h, - securityLevel, - onClick, - stellarExpertUrl, -}: SwapTokenRowProps) => { - const { t } = useTranslation(); - const canonical = issuerKey ? `${code}:${issuerKey}` : "native"; - const isScamAsset = - securityLevel === SecurityLevel.MALICIOUS || - securityLevel === SecurityLevel.SUSPICIOUS; - - const copyAddress = async () => { - if (!issuerKey) return; - await navigator.clipboard.writeText(issuerKey); - }; - - const viewOnExpert = () => { - openTab(`${stellarExpertUrl}/asset/${code}-${issuerKey}`); - }; - - return ( -
-
-
- - {!isHeld && } -
-
-
{code}
- {domain ? ( -
- {formatDomain(domain)} -
- ) : null} -
-
- - {isHeld ? ( -
-
- {fiatValue || "--"} -
- {percentChange24h ? ( -
- {percentChange24h} -
- ) : null} -
- ) : ( - - - - - - - - - - - - )} -
- ); -}; diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss b/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss deleted file mode 100644 index 200b1da7fc..0000000000 --- a/extension/src/popup/components/swap/SwapAsset/SwapTokenRow/styles.scss +++ /dev/null @@ -1,71 +0,0 @@ -.SwapTokenRow { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 0; - cursor: pointer; - - &__body { - display: flex; - align-items: center; - gap: 0.75rem; - flex: 1; - min-width: 0; - } - - &__icon { - position: relative; - } - - &__title { - display: flex; - flex-direction: column; - min-width: 0; - - &__code { - font-weight: var(--sds-fw-semi-bold, 600); - } - - &__domain { - color: var(--sds-clr-gray-09); - font-size: 0.75rem; - } - } - - &__value { - text-align: right; - - &__change { - color: var(--sds-clr-gray-09); - font-size: 0.75rem; - } - } - - &__menu { - background: transparent; - border: none; - cursor: pointer; - color: var(--sds-clr-gray-11); - padding: 0.25rem; - - &__content { - display: flex; - flex-direction: column; - background: var(--sds-clr-gray-03); - border: 1px solid var(--sds-clr-gray-06); - border-radius: 0.5rem; - padding: 0.25rem; - z-index: 10; - } - - &__item { - background: transparent; - border: none; - text-align: left; - padding: 0.5rem 0.75rem; - cursor: pointer; - color: var(--sds-clr-gray-12); - white-space: nowrap; - } - } -} diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 386fa5ac8b..89e8e4c553 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -1,9 +1,10 @@ import { useReducer, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import { captureException } from "@sentry/browser"; +import BigNumber from "bignumber.js"; import { NetworkDetails } from "@shared/constants/stellar"; -import { BlockAidScanAssetResult } from "@shared/api/types"; +import { ApiTokenPrices, BlockAidScanAssetResult } from "@shared/api/types"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; import { AssetType } from "@shared/api/types/account-balance"; @@ -15,7 +16,12 @@ import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRo import { SecurityLevel } from "popup/constants/blockaid"; import { searchAsset } from "popup/helpers/searchAsset"; import { splitVerifiedAssetCurrency } from "popup/helpers/assetList"; -import { isContractId, isAssetSac } from "popup/helpers/soroban"; +import { + isContractId, + isAssetSac, + formatTokenAmount, +} from "popup/helpers/soroban"; +import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { scanAssetBulk, isAssetMalicious, @@ -49,6 +55,8 @@ export interface SwapTokenRecord extends ManageAssetCurrency { isContract: boolean; requiresTrustline: boolean; securityLevel?: SecurityLevel; + /** Formatted held-token balance (held rows only). */ + tokenAmount?: string; fiatValue?: string; percentChange24h?: string; } @@ -81,6 +89,7 @@ export const EMPTY_RESULT: SwapTokenLookupResult = { const heldToRecord = ( balance: AssetType, icons: Record = {}, + tokenPrices: ApiTokenPrices = {}, ): SwapTokenRecord | null => { if (!("token" in balance) || !balance.token) { return null; @@ -95,6 +104,26 @@ const heldToRecord = ( const code = isNative ? "XLM" : token.code; const issuer = isNative ? "" : token.issuer?.key || ""; const canonical = isNative ? "native" : getCanonicalFromAsset(code, issuer); + + // Held-token balance, fiat value and 24h delta (mirrors the account-home row). + const total = new BigNumber( + (balance as { total?: BigNumber.Value }).total ?? 0, + ); + const rawAmount = + "contractId" in balance && "decimals" in balance + ? formatTokenAmount(total, (balance as { decimals: number }).decimals) + : total.toFixed(); + const tokenAmount = formatAmount(rawAmount); + const price = tokenPrices[canonical]; + const fiatValue = price?.currentPrice + ? `$${formatAmount( + roundUsdValue( + new BigNumber(price.currentPrice).multipliedBy(total).toString(), + ), + )}` + : undefined; + const percentChange24h = price?.percentagePriceChange24h || undefined; + return { code, issuer, @@ -103,6 +132,9 @@ const heldToRecord = ( // Held tokens carry no icon URL of their own — pull it from the account's // icons map (keyed by canonical) so "Your tokens" rows show real logos. image: icons[canonical] || undefined, + tokenAmount, + fiatValue, + percentChange24h, isHeld: true, isContract: false, requiresTrustline: false, @@ -149,6 +181,7 @@ export const buildSwapSections = ({ searchResults = [], isFallback = false, icons = {}, + tokenPrices = {}, }: { searchTerm: string; balances: AssetType[]; @@ -159,12 +192,13 @@ export const buildSwapSections = ({ searchResults?: ManageAssetCurrency[]; isFallback?: boolean; icons?: Record; + tokenPrices?: ApiTokenPrices; }): SwapTokenLookupResult => { const term = searchTerm.trim().toLowerCase(); const isSearch = term.length > 0; const heldRecords = balances - .map((b) => heldToRecord(b, icons)) + .map((b) => heldToRecord(b, icons, tokenPrices)) .filter((r): r is SwapTokenRecord => r !== null); const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); @@ -370,12 +404,14 @@ export const useSwapTokenLookup = () => { publicKey: _publicKey, networkDetails, icons = {}, + tokenPrices = {}, }: { searchTerm: string; balances: AssetType[]; publicKey: string; networkDetails: NetworkDetails; icons?: Record; + tokenPrices?: ApiTokenPrices; }): Promise => { // Cancel any in-flight request abortControllerRef.current?.abort(); @@ -398,6 +434,7 @@ export const useSwapTokenLookup = () => { balances, networkDetails, icons, + tokenPrices, isFallback: true, }), }); @@ -481,6 +518,7 @@ export const useSwapTokenLookup = () => { balances, networkDetails, icons, + tokenPrices, popular, verifiedAssets, unverifiedAssets, @@ -550,6 +588,7 @@ export const useSwapTokenLookup = () => { balances, networkDetails, icons, + tokenPrices, isFallback: true, }), }); diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index c33a1a50e8..ad9056fd81 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -86,12 +86,17 @@ export const SwapAsset = ({ resolvedFrom?.type === AppDataType.RESOLVED ? resolvedFrom.balances.icons : {}; + const tokenPrices = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.tokenPrices + : {}; lookupFetchData({ searchTerm: values.searchTerm, balances, publicKey, networkDetails, icons, + tokenPrices, }); } else { filterBalances(values.searchTerm); @@ -146,6 +151,7 @@ export const SwapAsset = ({ publicKey: resolvedFrom.publicKey, networkDetails, icons: resolvedFrom.balances.icons, + tokenPrices: resolvedFrom.tokenPrices, }); // eslint-disable-next-line react-hooks/exhaustive-deps -- re-run only when held balances resolve; lookupFetchData/networkDetails/searchTerm are intentionally excluded (search is driven by the debounced submit) }, [isDestination, fromState.data]); From 2d552e0ae3694f9f1f4110819da926b76be673d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 19:47:04 -0300 Subject: [PATCH 056/121] Render token icons as perfect circles regardless of source aspect ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asset-icon container used max-width/max-height, so it sized to the image's intrinsic aspect ratio — non-square source logos (e.g. wide banners) rendered as ellipses under border-radius: 50%. Use a fixed 2rem square so object-fit: cover crops every logo to a circle, matching mobile. Affects all AssetIcon usages (account home, send, swap picker, add-a-token). Co-Authored-By: Claude Opus 4.8 --- .../popup/components/account/AccountAssets/styles.scss | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/account/AccountAssets/styles.scss b/extension/src/popup/components/account/AccountAssets/styles.scss index f4436b7de9..b572ca44f9 100644 --- a/extension/src/popup/components/account/AccountAssets/styles.scss +++ b/extension/src/popup/components/account/AccountAssets/styles.scss @@ -16,8 +16,13 @@ $loader-light-color: #444961; &--logo { margin-right: 1rem; - max-width: 2rem; - max-height: 2rem; + // Fixed square (not max-width/height) so non-square source logos are + // cropped to a circle by object-fit: cover instead of rendering as an + // ellipse. + width: 2rem; + height: 2rem; + min-width: 2rem; + flex-shrink: 0; position: relative; img { From 1d920fd111a1117f4cfe5e8618b826181b2da11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 19:54:29 -0300 Subject: [PATCH 057/121] Share BalanceRow on the account-home balances list Render the account-home asset rows through the same BalanceRow used by the Swap "Your tokens" section, so both share one component. BalanceRow now omits the fiat cell when there's no price (matching home's no-price row), forwards retryAssetIconFetch to the icon, and shows a pointer cursor when clickable. All testids/behaviors are preserved (Account.test green). Note: BalanceRow imports AssetIcon from AccountAssets, so this introduces a render-time-only circular import; extracting AssetIcon into its own module is a sensible follow-up to remove it. Co-Authored-By: Claude Opus 4.8 --- .../src/popup/components/BalanceRow/index.tsx | 17 ++-- .../popup/components/BalanceRow/styles.scss | 4 + .../account/AccountAssets/index.tsx | 90 +++++-------------- 3 files changed, 37 insertions(+), 74 deletions(-) diff --git a/extension/src/popup/components/BalanceRow/index.tsx b/extension/src/popup/components/BalanceRow/index.tsx index d71eaec67f..b8c3a207ed 100644 --- a/extension/src/popup/components/BalanceRow/index.tsx +++ b/extension/src/popup/components/BalanceRow/index.tsx @@ -16,9 +16,11 @@ export interface BalanceRowProps { iconUrl?: string | null; isSuspicious?: boolean; isLPShare?: boolean; + retryAssetIconFetch?: (arg: { key: string; code: string }) => void; /** Formatted token balance, e.g. "123.45". */ amount: string; - /** Formatted fiat balance incl. symbol, e.g. "$12.34". Null → "--". */ + /** Formatted fiat balance incl. symbol, e.g. "$12.34". When null the fiat + * cell is omitted entirely (matches the account-home no-price row). */ fiatAmount?: string | null; /** Raw 24h % change number string (e.g. "1.23"); drives color + display. * Null → "--". */ @@ -42,6 +44,7 @@ export const BalanceRow = ({ iconUrl, isSuspicious = false, isLPShare = false, + retryAssetIconFetch, amount, fiatAmount, percentChange, @@ -52,13 +55,14 @@ export const BalanceRow = ({ deltaTestId, }: BalanceRowProps) => { const hasDelta = percentChange !== undefined && percentChange !== null; + const hasFiat = fiatAmount !== undefined && fiatAmount !== null; const deltaColor = hasDelta ? getPriceDeltaColor(new BigNumber(roundUsdValue(percentChange as string))) : ""; return (
{code} @@ -80,9 +85,11 @@ export const BalanceRow = ({
-
- {fiatAmount ?? "--"} -
+ {hasFiat && ( +
+ {fiatAmount} +
+ )}
-
null : () => handleClick(canonicalAsset)} - > -
- -
- {code} -
- {formatAmount(amountVal)} -
-
-
- {assetPrice ? ( -
-
- $ - {formatAmount( + code={code} + issuerKey={issuer?.key} + assetIcons={assetIcons} + isSuspicious={isSuspicious} + isLPShare={"liquidityPoolId" in rb && !!rb.liquidityPoolId} + retryAssetIconFetch={retryAssetIconFetch} + amount={formatAmount(amountVal)} + fiatAmount={ + assetPrice + ? `$${formatAmount( roundUsdValue( new BigNumber(assetPrice.currentPrice) .multipliedBy(rb.total) .toString(), ), - )} -
- {assetPrice.percentagePriceChange24h ? ( -
- {formatAmount( - roundUsdValue(assetPrice.percentagePriceChange24h), - )} - % -
- ) : ( -
- -- -
- )} -
- ) : ( -
- -- -
- )} -
+ )}` + : null + } + percentChange={assetPrice?.percentagePriceChange24h ?? null} + amountTestId="asset-amount" + fiatTestId={`asset-amount-${canonicalAsset}`} + deltaTestId={`asset-price-delta-${canonicalAsset}`} + onClick={isLP ? undefined : () => handleClick(canonicalAsset)} + /> e.preventDefault()} aria-describedby={undefined} From 22017aa141fd9c852deabd0b418f8f5bcca67e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 20:13:32 -0300 Subject: [PATCH 058/121] Give BalanceRow lists consistent row spacing Add 0.75rem top/bottom padding to BalanceRow so the Swap "Your tokens" list and the account-home balances list have the same ~1.5rem between rows as the other token lists. Removes the now-dead account-home margin rule (it targeted the old .AccountAssets__asset row, which is now a BalanceRow). Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/BalanceRow/styles.scss | 2 ++ extension/src/popup/views/Account/styles.scss | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/components/BalanceRow/styles.scss b/extension/src/popup/components/BalanceRow/styles.scss index c0d30a0e2e..fad26389bf 100644 --- a/extension/src/popup/components/BalanceRow/styles.scss +++ b/extension/src/popup/components/BalanceRow/styles.scss @@ -2,6 +2,8 @@ display: flex; align-items: center; justify-content: space-between; + // Same vertical rhythm as the other token lists (~1.5rem between rows). + padding: 0.75rem 0; color: var(--sds-clr-gray-12); font-size: 1rem; line-height: 1.5rem; diff --git a/extension/src/popup/views/Account/styles.scss b/extension/src/popup/views/Account/styles.scss index 778f80fbef..2b68d501b6 100644 --- a/extension/src/popup/views/Account/styles.scss +++ b/extension/src/popup/views/Account/styles.scss @@ -12,12 +12,6 @@ max-width: 60%; } - &__assets-wrapper { - .AccountAssets__asset { - margin: 2rem 0; - } - } - &__assets-button { display: flex; justify-content: center; From 08ca478d9a3ce66792759538b6b1e9fa8cfefdc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 20:14:23 -0300 Subject: [PATCH 059/121] Show the same "Your tokens" list in the Swap-from picker The source ("Swap from") picker rendered the legacy TokenList; render it through the same SwapPickerSections "Your tokens" list (BalanceRow) as the destination, for consistency. Export balancesToHeldRecords from the swap token lookup to map the source's held balances into the shared records; the local search filter still drives the list. Co-Authored-By: Claude Opus 4.8 --- .../SwapAsset/__tests__/SwapAsset.test.tsx | 8 ++-- .../SwapAsset/hooks/useSwapTokenLookup.ts | 18 +++++++++ .../popup/components/swap/SwapAsset/index.tsx | 26 ++++++++++--- .../__tests__/Swap.selectionType.test.tsx | 37 +++++++++---------- 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx index 10a1d30f18..7686f8c221 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -53,7 +53,7 @@ describe("SwapAsset selectionType", () => { afterEach(() => jest.restoreAllMocks()); - it("source: renders the 'Swap from' header and the held TokenList", () => { + it("source: renders the 'Swap from' header and the Your tokens list", () => { render( { , ); + // Source now reuses the same SwapPickerSections "Your tokens" list as the + // destination (held tokens only), not the legacy TokenList. expect(screen.getByText("Swap from")).toBeInTheDocument(); - expect(screen.getByTestId("token-list")).toBeInTheDocument(); - expect(screen.queryByTestId("swap-picker-sections")).toBeNull(); + expect(screen.getByTestId("swap-picker-sections")).toBeInTheDocument(); + expect(screen.queryByTestId("token-list")).toBeNull(); }); it("destination: renders the 'Swap to' header and SwapPickerSections", () => { diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 89e8e4c553..2fd54035c3 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -141,6 +141,24 @@ const heldToRecord = ( }; }; +/** + * Maps an account's held balances into SwapTokenRecords for the "Your tokens" + * list. Used directly by the Swap source picker (which shows held tokens only) + * and indirectly via buildSwapSections for the destination picker. + */ +export const balancesToHeldRecords = ({ + balances, + icons = {}, + tokenPrices = {}, +}: { + balances: AssetType[]; + icons?: Record; + tokenPrices?: ApiTokenPrices; +}): SwapTokenRecord[] => + balances + .map((b) => heldToRecord(b, icons, tokenPrices)) + .filter((r): r is SwapTokenRecord => r !== null); + /** * Converts a ManageAssetCurrency (search/popular result) into a SwapTokenRecord. */ diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index ad9056fd81..f11ee6c635 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -6,7 +6,6 @@ import { debounce } from "lodash"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; -import { TokenList } from "popup/components/InternalTransaction/TokenList"; import { SubviewHeader } from "popup/components/SubviewHeader"; import { openTab } from "popup/helpers/navigate"; import { View } from "popup/basics/layout/View"; @@ -19,7 +18,10 @@ import { getStellarExpertUrl } from "popup/helpers/account"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import type { DestinationTokenDetails } from "popup/ducks/transactionSubmission"; import { useGetSwapFromData } from "./hooks/useSwapFromData"; -import { useSwapTokenLookup } from "./hooks/useSwapTokenLookup"; +import { + useSwapTokenLookup, + balancesToHeldRecords, +} from "./hooks/useSwapTokenLookup"; import { SwapPickerSections } from "./SwapPickerSections"; import type { SwapPickerSectionsResult } from "./SwapPickerSections"; @@ -215,6 +217,18 @@ export const SwapAsset = ({ isNewAccount: true, }; + // The source ("Swap from") picker shows the same held "Your tokens" list as + // the destination — held tokens only, filtered locally by the search term. + const sourceResult: SwapPickerSectionsResult = { + yourTokens: balancesToHeldRecords({ balances, icons, tokenPrices }), + popular: [], + verified: [], + unverified: [], + hadSorobanMatches: false, + isFallback: false, + isNewAccount: heldBalancesForNewAccount.length === 0, + }; + return ( <> ) : ( - )}
diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx index 677c2d546f..bb1da8d030 100644 --- a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -152,9 +152,10 @@ describe("Swap selectionType wiring", () => { await waitFor(() => { expect(screen.getByText("Swap from")).toBeInTheDocument(); }); - // Source picker renders the held-only TokenList, not the discovery picker. - expect(screen.getByTestId("token-list")).toBeInTheDocument(); - expect(screen.queryByTestId("swap-picker-sections")).toBeNull(); + // Source picker reuses the same "Your tokens" list (SwapPickerSections) as + // the destination, not the legacy TokenList. + expect(screen.getByTestId("swap-picker-sections")).toBeInTheDocument(); + expect(screen.queryByTestId("token-list")).toBeNull(); }); it("opens the destination picker (Swap to) when the receive card's asset selector is clicked", async () => { @@ -176,23 +177,23 @@ describe("Swap selectionType wiring", () => { }); it("emits swapSourceSelected with the picked source on source pick", async () => { + const usdcBalance = { + token: { + code: "USDC", + issuer: { + key: "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + }, + }, + total: new BigNumber("100"), + available: new BigNumber("100"), + }; jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ state: { ...resolvedFromState, data: { ...resolvedFromState.data, - filteredBalances: [ - { - token: { - code: "USDC", - issuer: { - key: "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", - }, - }, - total: new BigNumber("100"), - available: new BigNumber("100"), - }, - ], + balances: { balances: [usdcBalance], icons: {} }, + filteredBalances: [usdcBalance], }, }, fetchData: jest.fn().mockResolvedValue(undefined), @@ -208,10 +209,8 @@ describe("Swap selectionType wiring", () => { fireEvent.click(selectors[0]); }); - // The held TokenList renders a clickable USDC row. - const usdcRow = await screen.findByTestId( - "SendRow-USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", - ); + // The "Your tokens" list renders a clickable USDC row. + const usdcRow = await screen.findByTestId("SwapTokenRow-USDC"); await act(async () => { fireEvent.click(usdcRow); }); From 0e239f3605029ba6c6da6a1fe5cc3407c2ad6692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 20:34:45 -0300 Subject: [PATCH 060/121] Fix "Your tokens" icons stuck loading in the Swap picker 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 --- .../src/popup/components/BalanceRow/index.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/BalanceRow/index.tsx b/extension/src/popup/components/BalanceRow/index.tsx index b8c3a207ed..48a5bc25c9 100644 --- a/extension/src/popup/components/BalanceRow/index.tsx +++ b/extension/src/popup/components/BalanceRow/index.tsx @@ -1,10 +1,12 @@ import React from "react"; import BigNumber from "bignumber.js"; +import { isEmpty } from "lodash"; import { AssetIcon } from "popup/components/account/AccountAssets"; import { AssetIcons } from "@shared/api/types"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { getPriceDeltaColor } from "popup/helpers/balance"; +import { getCanonicalFromAsset } from "helpers/stellar"; import "./styles.scss"; @@ -56,6 +58,18 @@ export const BalanceRow = ({ }: BalanceRowProps) => { const hasDelta = percentChange !== undefined && percentChange !== null; const hasFiat = fiatAmount !== undefined && fiatAmount !== null; + + // AssetIcon shows a perpetual loading state when assetIcons is empty (and the + // asset isn't XLM). Callers that pass a single iconUrl (e.g. the swap picker's + // held list) would otherwise hit that; synthesize a one-entry map for them. + const canonical = + code === "XLM" && !issuerKey + ? "native" + : getCanonicalFromAsset(code, issuerKey); + const resolvedIcons = + code !== "XLM" && isEmpty(assetIcons) + ? { [canonical]: iconUrl ?? "" } + : assetIcons; const deltaColor = hasDelta ? getPriceDeltaColor(new BigNumber(roundUsdValue(percentChange as string))) : ""; @@ -69,7 +83,7 @@ export const BalanceRow = ({ >
Date: Thu, 25 Jun 2026 20:52:28 -0300 Subject: [PATCH 061/121] Restyle Swap Verified/Unverified info sheets to match Figma 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 --- .../components/InfoBottomSheet/index.tsx | 90 +++++++++++++++++++ .../components/InfoBottomSheet/styles.scss | 81 +++++++++++++++++ .../InfoSheets/__tests__/InfoSheets.test.tsx | 10 ++- .../swap/SwapAsset/InfoSheets/index.tsx | 66 ++++++-------- .../src/popup/locales/en/translation.json | 3 + .../src/popup/locales/pt/translation.json | 3 + 6 files changed, 211 insertions(+), 42 deletions(-) create mode 100644 extension/src/popup/components/InfoBottomSheet/index.tsx create mode 100644 extension/src/popup/components/InfoBottomSheet/styles.scss diff --git a/extension/src/popup/components/InfoBottomSheet/index.tsx b/extension/src/popup/components/InfoBottomSheet/index.tsx new file mode 100644 index 0000000000..9857933960 --- /dev/null +++ b/extension/src/popup/components/InfoBottomSheet/index.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Icon } from "@stellar/design-system"; + +import { SlideupModal } from "popup/components/SlideupModal"; + +import "./styles.scss"; + +/** Color treatment for the top-left icon badge. */ +export type InfoSheetBadgeVariant = "brand" | "neutral"; + +interface InfoSheetContentProps { + /** Icon rendered inside the top-left badge. */ + icon: React.ReactNode; + /** Badge color: "brand" (lilac) or "neutral" (gray). */ + badgeVariant?: InfoSheetBadgeVariant; + title: string; + /** Label for the full-width dismiss button (e.g. "Close", "Got it"). */ + actionLabel: string; + onClose: () => void; + children: React.ReactNode; + "data-testid"?: string; + /** Test id for the circular X close button. */ + closeTestId?: string; +} + +/** + * Presentational body of an informational sheet: a colored icon badge plus a + * circular close button on top, a title and body, and a full-width dismiss + * button. Modal-agnostic so it can be wrapped in a SlideupModal (see + * InfoBottomSheet below) or rendered directly inside a review pane. + */ +export const InfoSheetContent = ({ + icon, + badgeVariant = "brand", + title, + actionLabel, + onClose, + children, + "data-testid": dataTestId, + closeTestId, +}: InfoSheetContentProps) => { + const { t } = useTranslation(); + return ( +
+
+
+ {icon} +
+ +
+
+

{title}

+
{children}
+
+ +
+ ); +}; + +interface InfoBottomSheetProps extends InfoSheetContentProps { + isOpen: boolean; +} + +/** {@link InfoSheetContent} wrapped in a slide-up modal. */ +export const InfoBottomSheet = ({ + isOpen, + onClose, + ...rest +}: InfoBottomSheetProps) => ( + onClose()}> + + +); diff --git a/extension/src/popup/components/InfoBottomSheet/styles.scss b/extension/src/popup/components/InfoBottomSheet/styles.scss new file mode 100644 index 0000000000..75c0d87b9d --- /dev/null +++ b/extension/src/popup/components/InfoBottomSheet/styles.scss @@ -0,0 +1,81 @@ +@use "../../styles/utils.scss" as *; + +.InfoSheet { + display: flex; + flex-direction: column; + gap: pxToRem(24px); + padding: pxToRem(24px); + + &__top { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__badge { + display: flex; + align-items: center; + justify-content: center; + width: pxToRem(32px); + height: pxToRem(32px); + border-radius: pxToRem(8px); + border: 1px solid transparent; + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + } + + &--brand { + background-color: var(--sds-clr-lilac-03); + border-color: var(--sds-clr-lilac-06); + color: var(--sds-clr-lilac-11); + } + + &--neutral { + background-color: var(--sds-clr-gray-03); + border-color: var(--sds-clr-gray-06); + color: var(--sds-clr-gray-11); + } + } + + &__close { + display: flex; + align-items: center; + justify-content: center; + width: pxToRem(32px); + height: pxToRem(32px); + padding: 0; + border: 0; + border-radius: 50%; + background-color: var(--sds-clr-gray-03); + color: var(--sds-clr-gray-11); + cursor: pointer; + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + } + } + + &__text { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + } + + &__title { + margin: 0; + color: var(--sds-clr-gray-12); + font-size: pxToRem(18px); + font-weight: var(--font-weight-medium); + line-height: pxToRem(26px); + } + + &__body { + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + font-weight: var(--font-weight-regular); + line-height: pxToRem(20px); + } +} diff --git a/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx b/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx index c72a1644fb..49acf907fb 100644 --- a/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx @@ -10,18 +10,20 @@ jest.mock("react-i18next", () => ({ describe("Token info sheets", () => { it("VerifiedTokenInfoSheet renders its copy when open", () => { render(); + expect(screen.getByText("Verified token")).toBeInTheDocument(); expect( screen.getByText( - "Freighter uses asset lists to verify assets before interactions.", + "Freighter uses asset lists to verify assets before interactions. You can define your own assets lists in Settings.", ), ).toBeInTheDocument(); }); it("UnverifiedTokenInfoSheet renders its caution copy when open", () => { render(); + expect(screen.getByText("Unverified token")).toBeInTheDocument(); expect( screen.getByText( - "These tokens are not on any of your lists. Proceed with caution.", + "These assets are not on any of your lists. Proceed with caution before adding.", ), ).toBeInTheDocument(); }); @@ -29,7 +31,7 @@ describe("Token info sheets", () => { it("VerifiedTokenInfoSheet calls onClose when dismiss button is clicked", () => { const mockOnClose = jest.fn(); render(); - const dismissButton = screen.getByText("Got it"); + const dismissButton = screen.getByText("Close"); fireEvent.click(dismissButton); expect(mockOnClose).toHaveBeenCalled(); }); @@ -37,7 +39,7 @@ describe("Token info sheets", () => { it("UnverifiedTokenInfoSheet calls onClose when dismiss button is clicked", () => { const mockOnClose = jest.fn(); render(); - const dismissButton = screen.getByText("Got it"); + const dismissButton = screen.getByText("Close"); fireEvent.click(dismissButton); expect(mockOnClose).toHaveBeenCalled(); }); diff --git a/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx b/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx index 00a5ee7e1c..acdb716846 100644 --- a/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx @@ -1,8 +1,8 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Button } from "@stellar/design-system"; +import { Icon } from "@stellar/design-system"; -import { SlideupModal } from "popup/components/SlideupModal"; +import { InfoBottomSheet } from "popup/components/InfoBottomSheet"; interface InfoSheetProps { isOpen: boolean; @@ -12,24 +12,19 @@ interface InfoSheetProps { export const VerifiedTokenInfoSheet = ({ isOpen, onClose }: InfoSheetProps) => { const { t } = useTranslation(); return ( - onClose()}> -
-
{t("Verified tokens")}
-

- {t( - "Freighter uses asset lists to verify assets before interactions.", - )} -

- -
-
+ } + title={t("Verified token")} + actionLabel={t("Close")} + > + {t( + "Freighter uses asset lists to verify assets before interactions. You can define your own assets lists in Settings.", + )} + ); }; @@ -39,23 +34,18 @@ export const UnverifiedTokenInfoSheet = ({ }: InfoSheetProps) => { const { t } = useTranslation(); return ( - onClose()}> -
-
{t("Unverified tokens")}
-

- {t( - "These tokens are not on any of your lists. Proceed with caution.", - )} -

- -
-
+ } + title={t("Unverified token")} + actionLabel={t("Close")} + > + {t( + "These assets are not on any of your lists. Proceed with caution before adding.", + )} + ); }; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index a7e7ce26ee..3f58cf0c49 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -261,6 +261,7 @@ "Freighter provides access to third-party dApps, protocols, and tokens for informational purposes only.": "Freighter provides access to third-party dApps, protocols, and tokens for informational purposes only.", "Freighter uses asset lists to check assets you interact with.": "Freighter uses asset lists to check assets you interact with.", "Freighter uses asset lists to verify assets before interactions.": "Freighter uses asset lists to verify assets before interactions.", + "Freighter uses asset lists to verify assets before interactions. You can define your own assets lists in Settings.": "Freighter uses asset lists to verify assets before interactions. You can define your own assets lists in Settings.", "Freighter Wallet": "Freighter Wallet", "Freighter was unable to switch to this account": "Freighter was unable to switch to this account", "Freighter was unable update this account’s name": "Freighter was unable update this account’s name", @@ -706,6 +707,7 @@ "Unlock": "Unlock", "Unsupported signing method": "Unsupported signing method", "Unverified": "Unverified", + "Unverified token": "Unverified token", "Unverified tokens": "Unverified tokens", "Upload Contract Wasm": "Upload Contract Wasm", "Use caution when connecting to domains without an SSL certificate.": "Use caution when connecting to domains without an SSL certificate.", @@ -719,6 +721,7 @@ "Value": "Value", "Verification with": "Verification with", "Verified": "Verified", + "Verified token": "Verified token", "Verified tokens": "Verified tokens", "Version": "Version", "View": "View", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 3f5286bc7b..c378d3063a 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -261,6 +261,7 @@ "Freighter provides access to third-party dApps, protocols, and tokens for informational purposes only.": "O Freighter fornece acesso a dApps, protocolos e tokens de terceiros apenas para fins informativos.", "Freighter uses asset lists to check assets you interact with.": "O Freighter usa listas de ativos para verificar os ativos com os quais você interage.", "Freighter uses asset lists to verify assets before interactions.": "O Freighter usa listas de ativos para verificar ativos antes das interações.", + "Freighter uses asset lists to verify assets before interactions. You can define your own assets lists in Settings.": "O Freighter usa listas de ativos para verificar ativos antes das interações. Você pode definir suas próprias listas de ativos nas Configurações.", "Freighter Wallet": "Carteira Freighter", "Freighter was unable to switch to this account": "O Freighter não conseguiu alternar para esta conta", "Freighter was unable update this account’s name": "O Freighter não conseguiu atualizar o nome desta conta", @@ -706,6 +707,7 @@ "Unlock": "Desbloquear", "Unsupported signing method": "Método de assinatura não suportado", "Unverified": "Unverified", + "Unverified token": "Token não verificado", "Unverified tokens": "Unverified tokens", "Upload Contract Wasm": "Carregar Wasm do Contrato", "Use caution when connecting to domains without an SSL certificate.": "Use cautela ao se conectar a domínios sem um certificado SSL.", @@ -719,6 +721,7 @@ "Value": "Valor", "Verification with": "Verificação com", "Verified": "Verified", + "Verified token": "Token verificado", "Verified tokens": "Verified tokens", "Version": "Versão", "View": "Ver", From 9145652c14722a9f8c2e62c92b9113fe6e1674c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 21:03:17 -0300 Subject: [PATCH 062/121] Restyle trustline banner and info sheet to match Figma 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 --- .../components/TrustlineBanner.tsx | 21 ++++----- .../components/TrustlineInfoSheet.tsx | 45 +++++++------------ .../__tests__/TrustlineInfoSheet.test.tsx | 9 +--- .../ReviewTransaction/styles.scss | 28 +++++++++--- .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + 6 files changed, 51 insertions(+), 54 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx index 5c913d8746..babd436cbf 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Icon, Notification } from "@stellar/design-system"; +import { Icon } from "@stellar/design-system"; interface TrustlineBannerProps { tokenCode: string; @@ -13,20 +13,17 @@ export const TrustlineBanner = ({ }: TrustlineBannerProps) => { const { t } = useTranslation(); return ( -
- } - title={t("This will add a trustline to {{code}}", { code: tokenCode })} - > -
- -
-
-
+ + + {t("This will add a trustline to {{code}}", { code: tokenCode })} + + + ); }; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx index f5f36b1029..c95cfb88c2 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx @@ -2,6 +2,8 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Icon } from "@stellar/design-system"; +import { InfoSheetContent } from "popup/components/InfoBottomSheet"; + interface TrustlineInfoSheetProps { tokenCode: string; onClose: () => void; @@ -13,34 +15,19 @@ export const TrustlineInfoSheet = ({ }: TrustlineInfoSheetProps) => { const { t } = useTranslation(); return ( -
-
-
- -
-
- -
-
-
- {t("Adding a trustline to {{code}}", { code: tokenCode })} -
-
-
- {t( - "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", - )} -
-
- {t( - "This reserve is refundable. Remove the trustline later to get it back.", - )} -
-
-
+ } + title={t("This will add a trustline to {{code}}", { code: tokenCode })} + actionLabel={t("Got it")} + onClose={onClose} + > + {t( + "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", + { code: tokenCode }, + )} + ); }; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx index ef4aba0e43..7824cf6035 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx @@ -15,7 +15,7 @@ describe("TrustlineInfoSheet", () => { expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); }); - it("renders the 0.5 XLM reserve line and the refundable line", () => { + it("renders the reserve explanation", () => { const onClose = jest.fn(); render( @@ -24,12 +24,7 @@ describe("TrustlineInfoSheet", () => { ); expect( screen.getByText( - "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", - ), - ).toBeInTheDocument(); - expect( - screen.getByText( - "This reserve is refundable. Remove the trustline later to get it back.", + "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", ), ).toBeInTheDocument(); }); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss index ef2063a445..34b3e5ad94 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss @@ -292,16 +292,32 @@ } &__TrustlineBanner { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(8px); + width: 100%; + padding: pxToRem(8px) pxToRem(16px); + border: 0; + border-radius: pxToRem(12px); + background-color: var(--sds-clr-lilac-03); + color: var(--sds-clr-lilac-11); cursor: pointer; - .Notification { - background-color: var(--sds-clr-lilac-03, #2a2140); - border-color: var(--sds-clr-lilac-06, #5b3aa8); + &__Label { + display: flex; + align-items: center; + gap: pxToRem(8px); + font-size: pxToRem(12px); + font-weight: var(--font-weight-medium); + line-height: pxToRem(18px); + text-align: left; } - &__Action { - display: flex; - justify-content: flex-end; + svg { + width: pxToRem(14px); + height: pxToRem(14px); + flex-shrink: 0; } } } diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 3f58cf0c49..311f8047eb 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -659,6 +659,7 @@ "to": "to", "To access your wallet, click Freighter from your browser Extensions browser menu.": "To access your wallet, click Freighter from your browser Extensions browser menu.", "To create a new account you need to send at least 1 XLM to it.": "To create a new account you need to send at least 1 XLM to it.", + "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", "To start using this account, fund it with at least 1 XLM.": "To start using this account, fund it with at least 1 XLM.", "Toggle Assets": "Toggle Assets", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index c378d3063a..4aa0ce3433 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -659,6 +659,7 @@ "to": "para", "To access your wallet, click Freighter from your browser Extensions browser menu.": "Para acessar sua carteira, clique em Freighter no menu de Extensões do seu navegador.", "To create a new account you need to send at least 1 XLM to it.": "Para criar uma nova conta, você precisa enviar pelo menos 1 XLM para ela.", + "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "Para manter {{code}} na sua carteira, a Stellar exige uma linha de confiança. 0,5 XLM serão reservados do seu saldo. Você pode recuperá-los removendo a linha de confiança após o saldo de {{code}} ficar zerado.", "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "Para manter um novo ativo, sua conta reserva uma única vez 0,5 XLM para a linha de confiança.", "To start using this account, fund it with at least 1 XLM.": "Para começar a usar esta conta, financie-a com pelo menos 1 XLM.", "Toggle Assets": "Alternar Ativos", From 02e66f1c33b55a0e2bcc034687b3c638e0834610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 21:05:19 -0300 Subject: [PATCH 063/121] Filter the Swap-from list from the first character typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../swap/SwapAsset/hooks/useSwapFromData.tsx | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx index b6ab94033b..47798b5223 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx @@ -103,28 +103,24 @@ export function useGetSwapFromData(getBalancesOptions: { } const balances = resolvedSwapData?.balances.balances || []; - const filtered = - term?.length > 2 - ? balances.filter((balance) => { - if ( - "token" in balance && - balance.token.code.toLowerCase().includes(term) - ) - return true; - if ( - "token" in balance && - "issuer" in balance.token && - balance.token.issuer.key.toLowerCase().includes(term) - ) - return true; - if ( - "contractId" in balance && - balance.contractId.toLowerCase().includes(term) - ) - return true; - return false; - }) - : balances; + // Filter from the first character (token codes can be 1-2 letters), matching + // the destination ("Swap to") search. The empty-term case is handled above. + const filtered = balances.filter((balance) => { + if ("token" in balance && balance.token.code.toLowerCase().includes(term)) + return true; + if ( + "token" in balance && + "issuer" in balance.token && + balance.token.issuer.key.toLowerCase().includes(term) + ) + return true; + if ( + "contractId" in balance && + balance.contractId.toLowerCase().includes(term) + ) + return true; + return false; + }); const payload = { ...resolvedSwapData, filteredBalances: filtered, From db4de25178af67b8ad2fa84224b7b4d642b14771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 21:14:23 -0300 Subject: [PATCH 064/121] Make Swap "Your tokens" classic-only, fiat-sorted, and always visible - 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 --- .../__tests__/SwapPickerSections.test.tsx | 16 ++++++---- .../SwapAsset/SwapPickerSections/index.tsx | 6 ++-- .../__tests__/useSwapTokenLookup.test.ts | 32 +++++++++++++++++++ .../SwapAsset/hooks/useSwapTokenLookup.ts | 25 ++++++--------- 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index ba6e308ce4..61e0291186 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -159,25 +159,27 @@ describe("SwapPickerSections", () => { ).toBeInTheDocument(); }); - it("excludes hiddenAssets (the swap source) from every section", () => { + it("keeps held Your tokens visible but excludes hiddenAssets from discover sections", () => { render( , ); - // The hidden source asset (XLM) is filtered out of all sections... - expect(screen.queryByTestId("row-XLM")).toBeNull(); - // ...while other held/popular tokens remain. + // "Your tokens" is never filtered — the held XLM stays visible even though + // it is the hidden (already-selected) source asset. + expect(screen.getByTestId("row-XLM")).toBeInTheDocument(); expect(screen.getByTestId("row-USDC")).toBeInTheDocument(); - expect(screen.getByTestId("row-AQUA")).toBeInTheDocument(); + // Discover sections still drop hidden assets (AQUA) but keep the rest. + expect(screen.queryByTestId("row-AQUA")).toBeNull(); + expect(screen.getByTestId("row-DOGET")).toBeInTheDocument(); }); it("idle + new account + no popular: renders nothing (no generic empty state)", () => { diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx index 1e83f34cd8..8ad987b225 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx @@ -78,9 +78,11 @@ export const SwapPickerSections = ({ const isSearching = searchTerm.trim().length > 0; - // Exclude hidden canonicals (the swap source asset) from every section. + // Exclude hidden canonicals (the other side's asset) from the discover + // sections. "Your tokens" is intentionally NOT filtered — every held token + // should stay visible even when it is already selected on the other side. const hidden = new Set(hiddenAssets); - const yourTokens = result.yourTokens.filter((r) => !hidden.has(r.canonical)); + const yourTokens = result.yourTokens; const popular = result.popular.filter((r) => !hidden.has(r.canonical)); const verified = result.verified.filter((r) => !hidden.has(r.canonical)); const unverified = result.unverified.filter((r) => !hidden.has(r.canonical)); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts index 1c2a13a5b8..e7550a876f 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts @@ -64,6 +64,38 @@ describe("buildSwapSections — idle (no search term)", () => { expect(result.sections.yourTokens[0].code).toBe("AQUA"); expect(result.sections.yourTokens[0].image).toBe("https://icons/aqua.png"); }); + + it("excludes held Soroban (contract) tokens — Classic assets only", () => { + const heldSoroban = { + token: { code: "SRBN", issuer: { key: "GSRBN" } }, + contractId: "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7", + total: "5", + decimals: 7, + } as any; + const result = buildSwapSections({ + searchTerm: "", + balances: [heldAqua, heldSoroban], + networkDetails: MAINNET, + }); + expect(result.sections.yourTokens.map((r) => r.code)).toEqual(["AQUA"]); + }); + + it("sorts Your tokens by descending fiat value", () => { + const result = buildSwapSections({ + searchTerm: "", + balances: [heldAqua, heldXlm], + networkDetails: MAINNET, + tokenPrices: { + "AQUA:GBNZ": { currentPrice: "0.01" }, + native: { currentPrice: "0.5" }, + } as any, + }); + // AQUA: 100 * 0.01 = 1; XLM: 50 * 0.5 = 25 -> XLM sorts first. + expect(result.sections.yourTokens.map((r) => r.code)).toEqual([ + "XLM", + "AQUA", + ]); + }); }); describe("buildSwapSections — search term", () => { diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 2fd54035c3..3d65125adb 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -16,12 +16,9 @@ import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRo import { SecurityLevel } from "popup/constants/blockaid"; import { searchAsset } from "popup/helpers/searchAsset"; import { splitVerifiedAssetCurrency } from "popup/helpers/assetList"; -import { - isContractId, - isAssetSac, - formatTokenAmount, -} from "popup/helpers/soroban"; +import { isContractId, isAssetSac } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; +import { sortBalancesByValue } from "popup/helpers/balance"; import { scanAssetBulk, isAssetMalicious, @@ -91,7 +88,10 @@ const heldToRecord = ( icons: Record = {}, tokenPrices: ApiTokenPrices = {}, ): SwapTokenRecord | null => { - if (!("token" in balance) || !balance.token) { + // Classic-only: swaps run over the Classic path, so the "Your tokens" list + // shows native + classic assets only. Exclude liquidity-pool shares (no token) + // and custom Soroban contract tokens (carry a contractId). + if (!("token" in balance) || !balance.token || "contractId" in balance) { return null; } const token = balance.token as { @@ -109,11 +109,7 @@ const heldToRecord = ( const total = new BigNumber( (balance as { total?: BigNumber.Value }).total ?? 0, ); - const rawAmount = - "contractId" in balance && "decimals" in balance - ? formatTokenAmount(total, (balance as { decimals: number }).decimals) - : total.toFixed(); - const tokenAmount = formatAmount(rawAmount); + const tokenAmount = formatAmount(total.toFixed()); const price = tokenPrices[canonical]; const fiatValue = price?.currentPrice ? `$${formatAmount( @@ -155,7 +151,8 @@ export const balancesToHeldRecords = ({ icons?: Record; tokenPrices?: ApiTokenPrices; }): SwapTokenRecord[] => - balances + // Sort by descending fiat value, matching the account-home balances list. + sortBalancesByValue(balances, tokenPrices) .map((b) => heldToRecord(b, icons, tokenPrices)) .filter((r): r is SwapTokenRecord => r !== null); @@ -215,9 +212,7 @@ export const buildSwapSections = ({ const term = searchTerm.trim().toLowerCase(); const isSearch = term.length > 0; - const heldRecords = balances - .map((b) => heldToRecord(b, icons, tokenPrices)) - .filter((r): r is SwapTokenRecord => r !== null); + const heldRecords = balancesToHeldRecords({ balances, icons, tokenPrices }); const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); // Classic-only filter: drop any Soroban (non-SAC) contract record. From aca6604f3c7ebd2dc02f79120db51a63b5064271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 21:17:55 -0300 Subject: [PATCH 065/121] Seed recommendedFee in XLM so Swap shows 0.00001, not "100 XLM" 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 --- .../src/popup/helpers/__tests__/useNetworkFees.test.js | 10 +++++++--- extension/src/popup/helpers/useNetworkFees.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/extension/src/popup/helpers/__tests__/useNetworkFees.test.js b/extension/src/popup/helpers/__tests__/useNetworkFees.test.js index c470cbf187..d0598f2e84 100644 --- a/extension/src/popup/helpers/__tests__/useNetworkFees.test.js +++ b/extension/src/popup/helpers/__tests__/useNetworkFees.test.js @@ -9,6 +9,7 @@ import { BASE_FEE } from "stellar-sdk"; import { useSelector } from "react-redux"; import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; +import { stroopToXlm } from "helpers/stellar"; jest.mock("react-redux", () => ({ useSelector: jest.fn(), @@ -37,7 +38,10 @@ describe("getNetworkCongestionTranslation", () => { }); it("returns translated 'Low' for NetworkCongestion.LOW", () => { - const result = getNetworkCongestionTranslation(mockT, NetworkCongestion.LOW); + const result = getNetworkCongestionTranslation( + mockT, + NetworkCongestion.LOW, + ); expect(mockT).toHaveBeenCalledWith("Low"); expect(result).toBe("Low"); }); @@ -135,7 +139,7 @@ describe("useNetworkFees (React 18 compatible)", () => { expect(hookResult.networkCongestion).toBe(NetworkCongestion.HIGH); }); - it("falls back to BASE_FEE on error", async () => { + it("falls back to the base fee (in XLM) on error", async () => { useSelector.mockReturnValue({ networkUrl: "https://testnet.stellar.org", networkPassphrase: "Test SDF Network ; September 2015", @@ -160,7 +164,7 @@ describe("useNetworkFees (React 18 compatible)", () => { await hookResult.fetchData(); }); - expect(hookResult.recommendedFee).toBe(BASE_FEE); + expect(hookResult.recommendedFee).toBe(stroopToXlm(BASE_FEE).toFixed()); expect(hookResult.networkCongestion).toBe(""); }); }); diff --git a/extension/src/popup/helpers/useNetworkFees.ts b/extension/src/popup/helpers/useNetworkFees.ts index 4f6eac47b5..d268153ad8 100644 --- a/extension/src/popup/helpers/useNetworkFees.ts +++ b/extension/src/popup/helpers/useNetworkFees.ts @@ -33,7 +33,13 @@ export const useNetworkFees = () => { const { networkUrl, networkPassphrase } = useSelector( settingsNetworkDetailsSelector, ); - const [recommendedFee, setRecommendedFee] = useState(BASE_FEE); + // recommendedFee is always expressed in XLM (the fetched value below is + // converted from stroops). Seed it with the base fee in XLM (0.00001) rather + // than the raw stroop BASE_FEE, so the fee label and XLM available-balance + // show a sane value on first render instead of "100 XLM". + const [recommendedFee, setRecommendedFee] = useState( + stroopToXlm(BASE_FEE).toFixed(), + ); const [networkCongestion, setNetworkCongestion] = useState( "" as NetworkCongestion, ); @@ -55,7 +61,7 @@ export const useNetworkFees = () => { } return { recommendedFee, networkCongestion }; } catch (e) { - setRecommendedFee(BASE_FEE); + setRecommendedFee(stroopToXlm(BASE_FEE).toFixed()); return { recommendedFee }; } }; From bc09db2aa0e4581a2ff6b20fe7ad0071a65e7f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 21:20:07 -0300 Subject: [PATCH 066/121] Show the picked non-held token's logo in the Swap receive picker 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 --- extension/src/popup/components/swap/SwapAmount/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index d14f4072c7..8fdc562552 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -429,7 +429,13 @@ export const SwapAmount = ({ const sendData = data; const assetIcon = sendData.icons[asset]; - const dstAssetIcon = sendData.icons[destinationAsset]; + // The icons map only carries held-token logos. A non-held destination token + // (picked from search/popular) isn't in it, so fall back to the icon URL + // captured on the picked token so the receive picker shows its logo too. + const dstAssetIcon = + sendData.icons[destinationAsset] || + transactionData.destinationTokenDetails?.iconUrl || + null; const prices = sendData.tokenPrices; const assetPrice = prices[asset] && prices[asset].currentPrice; const xlmPrice = prices["native"]?.currentPrice; From f2490f40ef80a247821f567a412779a7beec95e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 21:34:23 -0300 Subject: [PATCH 067/121] Rework the Swap direction toggle and reset the amount on source change 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 --- .../SwapAmount.directionToggle.test.tsx | 146 ++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 60 +++++-- 2 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx new file mode 100644 index 0000000000..6a259dfe1b --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { render, screen, fireEvent, within, act } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; + +const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; +const USDC_CANONICAL = `USDC:${USDC_ISSUER}`; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), +}; +const usdcBalance = { + token: { code: "USDC", issuer: { key: USDC_ISSUER } }, + total: new BigNumber("50"), + available: new BigNumber("50"), +}; + +const makeSwapData = (balances: unknown[]) => ({ + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances }, + tokenPrices: {}, +}); + +const renderAmount = (transactionData: Record) => + render( + + + , + ); + +describe("SwapAmount direction toggle", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + // Avoid the live-quote network call when a destination + amount are set. + jest + .spyOn(HorizonGetBestPath, "horizonGetBestPath") + .mockResolvedValue(null as any); + }); + afterEach(() => jest.restoreAllMocks()); + + const mockAmountData = (balances: unknown[]) => + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: makeSwapData(balances), + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + + it("swaps positions when the destination is a held token", async () => { + mockAmountData([nativeBalance, usdcBalance]); + // Source XLM, destination held USDC. + renderAmount({ destinationAsset: USDC_CANONICAL }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Swap direction")); + }); + + // Source becomes USDC, receive becomes XLM. + const sell = screen.getByTestId("swap-sell-card"); + const receive = screen.getByTestId("swap-receive-card"); + expect(within(sell).getByText("USDC")).toBeInTheDocument(); + expect(within(receive).getByText("XLM")).toBeInTheDocument(); + }); + + it("resets a non-held destination to (+) Select instead of moving it to source", async () => { + // USDC is NOT in the account balances -> non-held. + mockAmountData([nativeBalance]); + renderAmount({ + destinationAsset: USDC_CANONICAL, + destinationTokenDetails: { + tokenCode: "USDC", + issuer: USDC_ISSUER, + requiresTrustline: true, + decimals: 7, + iconUrl: "https://icons/usdc.png", + source: "search", + }, + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Swap direction")); + }); + + // The non-held USDC is dropped: source returns to "(+) Select" and the held + // XLM moves into the receive slot. + const sell = screen.getByTestId("swap-sell-card"); + const receive = screen.getByTestId("swap-receive-card"); + expect(within(sell).getByText("Select")).toBeInTheDocument(); + expect(within(receive).getByText("XLM")).toBeInTheDocument(); + expect(within(sell).queryByText("USDC")).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 8fdc562552..73a0c032ce 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -23,6 +23,8 @@ import { saveAmountUsd, saveAsset, saveDestinationAsset, + saveDestinationTokenDetails, + saveIsToken, saveSwapBestPath, saveTransactionFee, saveTransactionTimeout, @@ -48,6 +50,7 @@ import { openTab } from "popup/helpers/navigate"; import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; import { getAssetDecimals, getAvailableBalance } from "popup/helpers/soroban"; +import { getBalanceCanonicalKey } from "popup/helpers/balance"; import { AppDispatch } from "popup/App"; import { emitMetric } from "helpers/metrics"; import { AMOUNT_ERROR, InputType } from "helpers/transaction"; @@ -116,7 +119,9 @@ export const SwapAmount = ({ transactionTimeout, } = transactionData; const fee = transactionFee || recommendedFee; - const srcAsset = getAssetFromCanonical(asset); + // The source can be in the "(+) Select" (empty) state — e.g. after a + // direction swap whose destination was unset or a non-held token. + const srcAsset = asset ? getAssetFromCanonical(asset) : null; const dstAsset = destinationAsset ? getAssetFromCanonical(destinationAsset) : null; @@ -136,7 +141,7 @@ export const SwapAmount = ({ publicKey, networkDetails, simParams: { - sourceAsset: srcAsset, + sourceAsset: srcAsset!, destAsset: dstAsset!, amount, allowedSlippage, @@ -436,6 +441,14 @@ export const SwapAmount = ({ sendData.icons[destinationAsset] || transactionData.destinationTokenDetails?.iconUrl || null; + // A non-held destination token can never become the source (we only swap + // held/classic assets), so the direction toggle handles it specially. + // Detect it by its absence from the account balances. + const heldCanonicals = new Set( + sendData.userBalances.balances.map((b) => getBalanceCanonicalKey(b)), + ); + const destinationIsNonHeld = + Boolean(destinationAsset) && !heldCanonicals.has(destinationAsset); const prices = sendData.tokenPrices; const assetPrice = prices[asset] && prices[asset].currentPrice; const xlmPrice = prices["native"]?.currentPrice; @@ -473,11 +486,13 @@ export const SwapAmount = ({ ), ) : null; - const availableBalance = getAvailableBalance({ - assetCanonical: asset, - balances: sendData.userBalances.balances, - recommendedFee: fee, - }); + const availableBalance = asset + ? getAvailableBalance({ + assetCanonical: asset, + balances: sendData.userBalances.balances, + recommendedFee: fee, + }) + : "0"; const displayTotal = `${formatAmount(availableBalance)}`; const isAmountTooHigh = (inputType === "crypto" && @@ -489,7 +504,9 @@ export const SwapAmount = ({ new BigNumber(availableBalance), )); - const availableBalanceText = `${displayTotal} ${srcAsset.code} ${t("available")}`; + const availableBalanceText = srcAsset + ? `${displayTotal} ${srcAsset.code} ${t("available")}` + : ""; const availableBalanceFontSizePx = AVAILABLE_BALANCE_FONT_SIZES.find( ({ maxLen }) => availableBalanceText.length <= maxLen, )!.sizePx; @@ -543,6 +560,7 @@ export const SwapAmount = ({ variant="secondary" isLoading={simulationState.state === RequestState.LOADING} disabled={ + !asset || !destinationAsset || (inputType === "crypto" && new BigNumber(formik.values.amount).isZero()) || @@ -555,7 +573,7 @@ export const SwapAmount = ({ formik.submitForm(); }} > - {!destinationAsset + {!asset || !destinationAsset ? t("Select an asset") : new BigNumber(cleanAmount(formik.values.amount)).isZero() ? t("Enter an amount") @@ -590,17 +608,17 @@ export const SwapAmount = ({ amount={formik.values.amount} amountUsd={formik.values.amountUsd} amountFontSizeClass={getAmountFontSizeClass()} - assetCode={srcAsset.code} + assetCode={srcAsset ? srcAsset.code : ""} assetIcon={assetIcon} assetIcons={ - asset !== "native" ? { [asset]: assetIcon } : {} + asset && asset !== "native" ? { [asset]: assetIcon } : {} } - assetIssuerKey={srcAsset.issuer} + assetIssuerKey={srcAsset?.issuer} supportsUsd={Boolean(supportsUsd)} fiatLineText={ inputType === "crypto" ? `$${priceValueUsd || "0.00"}` - : `${priceValue || "0"} ${srcAsset.code}` + : `${priceValue || "0"} ${srcAsset ? srcAsset.code : ""}` } isAmountTooHigh={isAmountTooHigh} cryptoDecimals={assetDecimals} @@ -646,8 +664,22 @@ export const SwapAmount = ({ e.preventDefault(); emitMetric(METRIC_NAMES.swapDirectionToggled); const prevSrc = asset; - dispatch(saveAsset(destinationAsset)); + // A non-held destination can't become the source, so reset + // it to "(+) Select" instead of moving it into the sell + // slot; otherwise swap the two positions normally. + dispatch( + saveAsset(destinationIsNonHeld ? "" : destinationAsset), + ); dispatch(saveDestinationAsset(prevSrc)); + // The new destination (old source) and new source are both + // held/classic or empty — neither carries trustline/contract + // metadata. + dispatch(saveDestinationTokenDetails(null)); + dispatch(saveIsToken(false)); + // The amount was denominated in the old source token; reset + // it whenever the source token changes. + dispatch(saveAmount("0")); + dispatch(saveAmountUsd("0.00")); }} > From c5822d1d7c9759b3b713eec2dc742a8cac01944e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 22:17:35 -0300 Subject: [PATCH 068/121] Inline-bold the reserve amount in the trustline info sheet Render the trustline body via so "0.5 XLM will be reserved" is emphasized inline (medium weight, brighter text) per Figma, using a named component to keep the sentence a single translatable key. Co-Authored-By: Claude Opus 4.8 --- .../components/InfoBottomSheet/styles.scss | 6 ++ .../components/TrustlineInfoSheet.tsx | 11 ++-- .../__tests__/TrustlineInfoSheet.test.tsx | 56 +++++++++++++++++-- .../src/popup/locales/en/translation.json | 2 +- .../src/popup/locales/pt/translation.json | 2 +- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/extension/src/popup/components/InfoBottomSheet/styles.scss b/extension/src/popup/components/InfoBottomSheet/styles.scss index 75c0d87b9d..9735a14996 100644 --- a/extension/src/popup/components/InfoBottomSheet/styles.scss +++ b/extension/src/popup/components/InfoBottomSheet/styles.scss @@ -78,4 +78,10 @@ font-weight: var(--font-weight-regular); line-height: pxToRem(20px); } + + // Inline emphasis within body copy (e.g. the trustline reserve amount). + &__emphasis { + color: var(--sds-clr-gray-12); + font-weight: var(--font-weight-medium); + } } diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx index c95cfb88c2..17e3f66451 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Icon } from "@stellar/design-system"; import { InfoSheetContent } from "popup/components/InfoBottomSheet"; @@ -24,10 +24,11 @@ export const TrustlineInfoSheet = ({ actionLabel={t("Got it")} onClose={onClose} > - {t( - "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", - { code: tokenCode }, - )} + }} + /> ); }; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx index 7824cf6035..ff52dba432 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx @@ -4,6 +4,47 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { Wrapper } from "popup/__testHelpers__"; import { TrustlineInfoSheet } from "../TrustlineInfoSheet"; +// Local i18n mock that interpolates {{vars}} and renders with its named +// components, so the inline-bold body copy can be asserted (the global setup +// mock renders as empty for non-string children). +jest.mock("react-i18next", () => { + const ReactLib = require("react"); + const interpolate = (str: string, values: Record = {}) => + Object.keys(values).reduce( + (acc, key) => acc.split(`{{${key}}}`).join(String(values[key])), + str, + ); + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => + interpolate(key, opts), + i18n: { changeLanguage: () => Promise.resolve(), t: (k: string) => k }, + }), + Trans: ({ + i18nKey, + values, + components, + }: { + i18nKey: string; + values?: Record; + components: Record; + }) => { + const text = interpolate(i18nKey, values); + const parts = text.split(/(.*?)<\/bold>/); + return ReactLib.createElement( + ReactLib.Fragment, + null, + parts.map((part: string, i: number) => + i % 2 === 1 + ? ReactLib.cloneElement(components.bold, { key: String(i) }, part) + : part, + ), + ); + }, + initReactI18next: { type: "3rdParty", init: () => {} }, + }; +}); + describe("TrustlineInfoSheet", () => { it("renders the info sheet", () => { const onClose = jest.fn(); @@ -13,20 +54,23 @@ describe("TrustlineInfoSheet", () => { , ); expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); + expect( + screen.getByText("This will add a trustline to USDC"), + ).toBeInTheDocument(); }); - it("renders the reserve explanation", () => { + it("renders the reserve explanation with the reserve amount emphasized", () => { const onClose = jest.fn(); render( , ); - expect( - screen.getByText( - "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", - ), - ).toBeInTheDocument(); + // Body copy is token-specific and present. + expect(screen.getByText(/To hold USDC in your wallet/)).toBeInTheDocument(); + // The reserve amount renders as inline bold (a element). + const emphasized = screen.getByText("0.5 XLM will be reserved"); + expect(emphasized.tagName).toBe("STRONG"); }); it("fires onClose when the close button is clicked", () => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 311f8047eb..a83f0c14b4 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -659,7 +659,7 @@ "to": "to", "To access your wallet, click Freighter from your browser Extensions browser menu.": "To access your wallet, click Freighter from your browser Extensions browser menu.", "To create a new account you need to send at least 1 XLM to it.": "To create a new account you need to send at least 1 XLM to it.", - "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", + "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", "To start using this account, fund it with at least 1 XLM.": "To start using this account, fund it with at least 1 XLM.", "Toggle Assets": "Toggle Assets", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 4aa0ce3433..498559043b 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -659,7 +659,7 @@ "to": "para", "To access your wallet, click Freighter from your browser Extensions browser menu.": "Para acessar sua carteira, clique em Freighter no menu de Extensões do seu navegador.", "To create a new account you need to send at least 1 XLM to it.": "Para criar uma nova conta, você precisa enviar pelo menos 1 XLM para ela.", - "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "Para manter {{code}} na sua carteira, a Stellar exige uma linha de confiança. 0,5 XLM serão reservados do seu saldo. Você pode recuperá-los removendo a linha de confiança após o saldo de {{code}} ficar zerado.", + "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "Para manter {{code}} na sua carteira, a Stellar exige uma linha de confiança. 0,5 XLM serão reservados do seu saldo. Você pode recuperá-los removendo a linha de confiança após o saldo de {{code}} ficar zerado.", "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "Para manter um novo ativo, sua conta reserva uma única vez 0,5 XLM para a linha de confiança.", "To start using this account, fund it with at least 1 XLM.": "Para começar a usar esta conta, financie-a com pelo menos 1 XLM.", "Toggle Assets": "Alternar Ativos", From 4695fa2145bde8d43c49246d2cbb4b85fbe6bdea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 22:18:10 -0300 Subject: [PATCH 069/121] Style the Swap picker empty state as a filled card 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 --- .../swap/SwapAsset/SwapPickerSections/styles.scss | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss index a54eba2233..433ea8fa40 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss @@ -32,9 +32,20 @@ } } + // Empty-result message shown as a filled card (e.g. a search that matched + // only unsupported Soroban contract tokens, or no matches at all). &__empty { - padding: 2rem 1rem; + display: flex; + align-items: center; + justify-content: center; + margin-top: 0.75rem; + padding: 1rem 1.5rem; + border-radius: 0.75rem; + background-color: var(--sds-clr-gray-03); + color: var(--sds-clr-gray-11); + font-size: 0.875rem; + font-weight: var(--font-weight-medium); + line-height: 1.25rem; text-align: center; - color: var(--sds-clr-gray-09); } } From d6f63fe79fa5f80091cdba5b4d3f3509f8c621ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 22:18:40 -0300 Subject: [PATCH 070/121] Reset the other picker to "(+) Select" when the same token is picked 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 --- extension/src/popup/views/Swap/index.tsx | 13 ++ .../__tests__/Swap.selectionType.test.tsx | 143 +++++++++++++++++- 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index f22cebfbe3..bdcc678cd1 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -111,6 +111,13 @@ export const Swap = () => { dispatch(saveDestinationAsset(canonical)); dispatch(saveIsToken(isContract)); dispatch(saveDestinationTokenDetails(details ?? null)); + // Can't swap a token for itself: if it matches the current + // source, reset the source to "(+) Select". + if (canonical === transactionData.asset) { + dispatch(saveAsset("")); + dispatch(saveAmount("0")); + dispatch(saveAmountUsd("0.00")); + } emitMetric(METRIC_NAMES.swapDestinationSelected, { tokenCode: details?.tokenCode, tokenIssuer: details?.issuer, @@ -162,6 +169,12 @@ export const Swap = () => { dispatch(saveIsToken(isContract)); dispatch(saveAmount("0")); dispatch(saveAmountUsd("0.00")); + // Can't swap a token for itself: if it matches the current + // destination, reset the destination to "(+) Select". + if (canonical === transactionData.destinationAsset) { + dispatch(saveDestinationAsset("")); + dispatch(saveDestinationTokenDetails(null)); + } emitMetric(METRIC_NAMES.swapSourceSelected, { tokenCode: getAssetFromCanonical(canonical).code, tokenIssuer: getAssetFromCanonical(canonical).issuer, diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx index bb1da8d030..be47e9b1a5 100644 --- a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -4,6 +4,7 @@ import { screen, fireEvent, waitFor, + within, act, } from "@testing-library/react"; import BigNumber from "bignumber.js"; @@ -64,7 +65,10 @@ const emptyLookupResult = { isFallback: false, }; -const renderSwap = (transactionData: Record = {}) => +const renderSwap = ( + transactionData: Record = {}, + routes: string[] = ["/swap"], +) => render( = {}) => }, } as any } - routes={["/swap"]} + routes={routes} > , @@ -224,4 +228,139 @@ describe("Swap selectionType wiring", () => { source: "balances", }); }); + + it("resets the source to (+) Select when the destination picker picks the current source token", async () => { + const issuer = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; + const canonical = `USDC:${issuer}`; + const usdcBalance = { + token: { code: "USDC", issuer: { key: issuer } }, + total: new BigNumber("100"), + available: new BigNumber("100"), + }; + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: { + ...resolvedFromState, + data: { + ...resolvedFromState.data, + balances: { balances: [usdcBalance], icons: {} }, + filteredBalances: [usdcBalance], + }, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: { + sections: { + yourTokens: [ + { + canonical, + code: "USDC", + issuer, + domain: null, + image: "", + isHeld: true, + isContract: false, + requiresTrustline: false, + tokenAmount: "100", + fiatValue: null, + percentChange24h: null, + }, + ], + popular: [], + verified: [], + unverified: [], + }, + isSearch: false, + hadSorobanMatches: false, + isFallback: false, + }, + error: null, + }, + } as any); + + // Source is already USDC (set via the source_asset query param, which the + // Swap mount effect applies after resetting submission). + renderSwap({}, [`/swap?source_asset=${canonical}`]); + + const selectors = await screen.findAllByTestId( + "send-amount-edit-dest-asset", + ); + // [1] opens the destination ("Swap to") picker. + await act(async () => { + fireEvent.click(selectors[1]); + }); + + // "Your tokens" still lists USDC even though it is the current source. + const usdcRow = await screen.findByTestId("SwapTokenRow-USDC"); + await act(async () => { + fireEvent.click(usdcRow); + }); + + // Back on the amount screen: destination is now USDC and the source has + // been reset to "(+) Select" (you can't swap a token for itself). + await waitFor(() => { + expect(screen.getByTestId("swap-sell-card")).toBeInTheDocument(); + }); + expect( + within(screen.getByTestId("swap-sell-card")).getByText("Select"), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("swap-receive-card")).getByText("USDC"), + ).toBeInTheDocument(); + }); + + it("resets the destination to (+) Select when the source picker picks the current destination token", async () => { + const issuer = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; + const canonical = `USDC:${issuer}`; + const usdcBalance = { + token: { code: "USDC", issuer: { key: issuer } }, + total: new BigNumber("100"), + available: new BigNumber("100"), + }; + // Held USDC drives the source picker's "Your tokens" list. + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: { + ...resolvedFromState, + data: { + ...resolvedFromState.data, + balances: { balances: [usdcBalance], icons: {} }, + filteredBalances: [usdcBalance], + }, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + + // Destination is already USDC (set via the destination_asset query param). + renderSwap({}, [`/swap?destination_asset=${canonical}`]); + + const selectors = await screen.findAllByTestId( + "send-amount-edit-dest-asset", + ); + // [0] opens the source ("Swap from") picker. + await act(async () => { + fireEvent.click(selectors[0]); + }); + + const usdcRow = await screen.findByTestId("SwapTokenRow-USDC"); + await act(async () => { + fireEvent.click(usdcRow); + }); + + // Source is now USDC; the destination resets to "(+) Select" (you can't + // swap a token for itself). + await waitFor(() => { + expect(screen.getByTestId("swap-receive-card")).toBeInTheDocument(); + }); + expect( + within(screen.getByTestId("swap-sell-card")).getByText("USDC"), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("swap-receive-card")).getByText("Select"), + ).toBeInTheDocument(); + }); }); From 30c82f74753e60a207deb9f2613165a35db96dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Thu, 25 Jun 2026 22:19:15 -0300 Subject: [PATCH 071/121] Never hide the fiat line; keep the swap fee in XLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AmountCard/__tests__/index.test.tsx | 10 ++ .../components/amount/AmountCard/index.tsx | 47 ++++--- .../__tests__/SendAmount.fiatLabel.test.tsx | 85 +++++++++++ .../components/send/SendAmount/index.tsx | 4 +- .../__tests__/SwapAmount.fiatLabel.test.tsx | 133 ++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 40 +++--- 6 files changed, 276 insertions(+), 43 deletions(-) create mode 100644 extension/src/popup/components/send/SendAmount/__tests__/SendAmount.fiatLabel.test.tsx create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index 1f66326444..fc50505cc0 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -89,6 +89,16 @@ describe("AmountCard", () => { expect(screen.queryByTestId("amount-fiat-toggle")).toBeNull(); }); + it("always shows the fiat line (e.g. '--') even when USD is unavailable, without a toggle", () => { + render( + + + , + ); + expect(screen.getByText("--")).toBeInTheDocument(); + expect(screen.queryByTestId("amount-fiat-toggle")).toBeNull(); + }); + it("shows the input-type toggle when not read-only and USD is supported", () => { render( diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 435fb72bc3..e0ea1b78ea 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -239,30 +239,31 @@ export const AmountCard = ({
- {supportsUsd && ( -
-
- {fiatLineText} - {/* Read-only cards (e.g. the swap "You receive" card) show the - fiat value but cannot toggle input type, so omit the toggle. */} - {!isReadOnly && ( - - )} -
+ {/* The fiat line is always shown (callers pass "$0.00"/"--" when there is + no value); only the input-type toggle depends on a usable USD price. */} +
+
+ {fiatLineText} + {/* Read-only cards (e.g. the swap "You receive" card) show the fiat + value but cannot toggle input type, so omit the toggle. The toggle + also needs a usable USD price. */} + {!isReadOnly && supportsUsd && ( + + )}
- )} +
{isAmountTooHigh && (
diff --git a/extension/src/popup/components/send/SendAmount/__tests__/SendAmount.fiatLabel.test.tsx b/extension/src/popup/components/send/SendAmount/__tests__/SendAmount.fiatLabel.test.tsx new file mode 100644 index 0000000000..29772a4dad --- /dev/null +++ b/extension/src/popup/components/send/SendAmount/__tests__/SendAmount.fiatLabel.test.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { SendAmount } from "popup/components/send/SendAmount"; +import * as UseGetSendAmountData from "popup/components/send/SendAmount/hooks/useSendAmountData"; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), +}; + +const sendData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + publicKey: "G123", + networkDetails: { network: "PUBLIC" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices: {}, +}; + +const renderSend = () => + render( + + + , + ); + +describe("SendAmount fiat label", () => { + afterEach(() => jest.restoreAllMocks()); + + it("shows '--' for the fiat line when the asset has no price", () => { + jest.spyOn(UseGetSendAmountData, "useGetSendAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: sendData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + + renderSend(); + + // XLM has no price in tokenPrices -> the fiat line shows "--" (not hidden). + expect(screen.getByText("--")).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/send/SendAmount/index.tsx b/extension/src/popup/components/send/SendAmount/index.tsx index 99065c78a9..e4cb123205 100644 --- a/extension/src/popup/components/send/SendAmount/index.tsx +++ b/extension/src/popup/components/send/SendAmount/index.tsx @@ -804,7 +804,9 @@ export const SendAmount = ({ supportsUsd={Boolean(supportsUsd)} fiatLineText={ inputType === "crypto" - ? `$${priceValueUsd || "0.00"}` + ? assetPrice + ? `$${priceValueUsd || "0.00"}` + : "--" : `${formatAmount(effectiveTokenAmount || "0")} ${parsedSourceAsset.code}` } isAmountTooHigh={isAmountTooHigh} diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx new file mode 100644 index 0000000000..72ad16f6e7 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; + +const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; +const USDC_CANONICAL = `USDC:${USDC_ISSUER}`; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), +}; + +const makeSwapData = (tokenPrices: Record) => ({ + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "PUBLIC" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices, +}); + +const renderAmount = (transactionData: Record) => + render( + + + , + ); + +describe("SwapAmount fiat label", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest + .spyOn(HorizonGetBestPath, "horizonGetBestPath") + .mockResolvedValue(null as any); + }); + afterEach(() => jest.restoreAllMocks()); + + const mockAmountData = (tokenPrices: Record) => + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: makeSwapData(tokenPrices), + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + + it("shows '--' on both cards when the selected assets have no price", () => { + mockAmountData({}); + // Source XLM and destination USDC, neither priced. + renderAmount({ asset: "native", destinationAsset: USDC_CANONICAL }); + + expect( + within(screen.getByTestId("swap-sell-card")).getByText("--"), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("swap-receive-card")).getByText("--"), + ).toBeInTheDocument(); + }); + + it("shows '$0.00' (not hidden) for the '(+) Select' source state", () => { + mockAmountData({}); + // No source asset selected -> "(+) Select". + renderAmount({ asset: "", destinationAsset: "" }); + + // Both cards are in the "(+) Select" state and show $0.00, not "--". + expect( + within(screen.getByTestId("swap-sell-card")).getByText("$0.00"), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("swap-receive-card")).getByText("$0.00"), + ).toBeInTheDocument(); + }); + + it("shows the USD value when the source is priced", () => { + mockAmountData({ native: { currentPrice: "0.5" } }); + // 5 XLM * $0.5 = $2.5x on the sell card (exact formatting aside). + renderAmount({ asset: "native", amount: "5" }); + + expect( + within(screen.getByTestId("swap-sell-card")).getByText(/^\$2/), + ).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 73a0c032ce..796f281b03 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -451,7 +451,6 @@ export const SwapAmount = ({ Boolean(destinationAsset) && !heldCanonicals.has(destinationAsset); const prices = sendData.tokenPrices; const assetPrice = prices[asset] && prices[asset].currentPrice; - const xlmPrice = prices["native"]?.currentPrice; const dstAssetPrice = prices[destinationAsset]?.currentPrice; const assetDecimals = getAssetDecimals(asset, sendData.userBalances, isToken); const priceValue = assetPrice @@ -469,13 +468,6 @@ export const SwapAmount = ({ ), )}` : null; - const recommendedFeeUsd = xlmPrice - ? `$${formatAmount( - roundUsdValue( - new BigNumber(xlmPrice).multipliedBy(new BigNumber(fee)).toString(), - ), - )}` - : null; const supportsUsd = isMainnet(data.networkDetails) && assetPrice; const dstPriceValueUsd = dstAssetPrice ? formatAmount( @@ -526,9 +518,9 @@ export const SwapAmount = ({ {t("Fee")}: - - {inputType === "crypto" ? `${fee} XLM` : recommendedFeeUsd} - + {/* The network fee is always denominated in XLM, regardless of + whether the amount is being entered in crypto or fiat. */} + {`${fee} XLM`}
} diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 861a7a96ff..500299e5b6 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -8,6 +8,9 @@ const swapKeys = [ "No tokens match {{term}}", "You sell", "You receive", + "Insufficient balance", + "Not enough XLM for network fees", + "No quote available", ]; describe("swap i18n parity", () => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index a83f0c14b4..826696dcc2 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -316,6 +316,7 @@ "In this process, Freighter will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "In this process, Freighter will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.", "Inclusion Fee": "Inclusion Fee", "Inflation Destination": "Inflation Destination", + "Insufficient balance": "Insufficient balance", "Insufficient Balance": "Insufficient Balance", "Insufficient Fee": "Insufficient Fee", "INSUFFICIENT FUNDS FOR FEE": "INSUFFICIENT FUNDS FOR FEE", @@ -417,10 +418,12 @@ "No device detected.": "No device detected.", "No hidden collectibles": "No hidden collectibles", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", + "No quote available": "No quote available", "No tokens match {{term}}": "No tokens match {{term}}", "No transactions to show": "No transactions to show", "None": "None", "Not enough lumens": "Not enough lumens", + "Not enough XLM for network fees": "Not enough XLM for network fees", "Not funded": "Not funded", "Not migrated": "Not migrated", "Not on your lists": "Not on your lists", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 498559043b..588526b96e 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -316,6 +316,7 @@ "In this process, Freighter will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "Neste processo, o Freighter criará uma nova frase de backup para você e migrará seus lumens, trustlines e ativos para a nova conta.", "Inclusion Fee": "Taxa de Inclusão", "Inflation Destination": "Destino de Inflação", + "Insufficient balance": "Saldo insuficiente", "Insufficient Balance": "Saldo Insuficiente", "Insufficient Fee": "Taxa Insuficiente", "INSUFFICIENT FUNDS FOR FEE": "FUNDOS INSUFICIENTES PARA TAXA", @@ -417,10 +418,12 @@ "No device detected.": "Nenhum dispositivo detectado.", "No hidden collectibles": "Nenhum colecionável oculto", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", + "No quote available": "Nenhuma cotação disponível", "No tokens match {{term}}": "Nenhum token corresponde a {{term}}", "No transactions to show": "Nenhuma transação para mostrar", "None": "Nenhum", "Not enough lumens": "Lumens insuficientes", + "Not enough XLM for network fees": "XLM insuficiente para taxas de rede", "Not funded": "Não financiado", "Not migrated": "Não migrado", "Not on your lists": "Não está em suas listas", From 6a5c9eb7b24bd8de5272f9fcb8df8a9f0d0690b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sat, 27 Jun 2026 13:12:46 -0300 Subject: [PATCH 084/121] Show the Soroban hint when a pasted contract id returns no results 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 --- .../__tests__/SwapPickerSections.test.tsx | 14 ++++++++++++++ .../swap/SwapAsset/SwapPickerSections/index.tsx | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index 61e0291186..c76f30ac53 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -141,6 +141,20 @@ describe("SwapPickerSections", () => { expect(screen.getByTestId("swap-picker-empty-soroban")).toBeInTheDocument(); }); + it("Soroban empty state shown when the search term is a contract id with no results", () => { + render( + , + ); + + expect(screen.getByTestId("swap-picker-empty-soroban")).toBeInTheDocument(); + expect(screen.queryByTestId("swap-picker-empty")).toBeNull(); + }); + it("soft fallback notice rendered when isFallback", () => { render( Date: Sat, 27 Jun 2026 13:32:06 -0300 Subject: [PATCH 085/121] Simplify the swap record filter to honestly mirror mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwapAsset/hooks/useSwapTokenLookup.ts | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index c9b38efb55..4fe990fcbb 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -16,7 +16,7 @@ import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRo import { SecurityLevel } from "popup/constants/blockaid"; import { searchAsset } from "popup/helpers/searchAsset"; import { splitVerifiedAssetCurrency } from "popup/helpers/assetList"; -import { isContractId, isAssetSac } from "popup/helpers/soroban"; +import { isContractId } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { sortBalancesByValue } from "popup/helpers/balance"; import { @@ -192,7 +192,6 @@ const currencyToRecord = ( export const buildSwapSections = ({ searchTerm, balances, - networkDetails, popular = [], verifiedAssets = [], unverifiedAssets = [], @@ -203,6 +202,8 @@ export const buildSwapSections = ({ }: { searchTerm: string; balances: AssetType[]; + // Accepted for call-site symmetry with the lookup context; the record filter + // no longer needs the network (it keys purely on contract-id shape). networkDetails: NetworkDetails; popular?: TrendingAsset[]; verifiedAssets?: ManageAssetCurrency[]; @@ -218,27 +219,19 @@ export const buildSwapSections = ({ const heldRecords = balancesToHeldRecords({ balances, icons, tokenPrices }); const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); - // Classic-only filter: drop any Soroban (non-SAC) contract record. - // Side-effect: sets hadSorobanMatches when a Soroban record is encountered. + // Classic-only filter, mirroring mobile's isSorobanRecord + // (isContractId(record.asset)): drop any record whose issuer or contract is a + // contract id — i.e. a custom Soroban token. Classic CODE-ISSUER records are + // kept, including SAC-backed assets, which stellar.expert returns in their + // classic form (a bare SAC contract id has no classic representation to swap). + // Side-effect: sets hadSorobanMatches so the picker can show the "try a + // Classic token" empty state. let hadSorobanMatches = false; const isClassic = (asset: ManageAssetCurrency): boolean => { - // Check via explicit contract field - if (asset.contract && isContractId(asset.contract)) { - const sac = isAssetSac({ - asset: { - code: asset.code || "", - issuer: asset.issuer, - contract: asset.contract, - }, - networkDetails, - }); - if (!sac) { - hadSorobanMatches = true; - return false; - } - } - // Also catch assets whose issuer itself is a contract address (Soroban token) - if (asset.issuer && isContractId(asset.issuer)) { + const isSorobanContract = + (asset.contract && isContractId(asset.contract)) || + (asset.issuer && isContractId(asset.issuer)); + if (isSorobanContract) { hadSorobanMatches = true; return false; } From 90dcc0adb10aaade1bf0298a8fed59b52325fde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sat, 27 Jun 2026 13:45:03 -0300 Subject: [PATCH 086/121] Fold the destination token's Blockaid verdict into the swap review gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/ReviewTx.security.test.tsx | 76 +++++++++++++++++++ .../ReviewTransaction/index.tsx | 55 ++++++++++++-- .../constants/__tests__/blockaid.test.ts | 42 ++++++++++ extension/src/popup/constants/blockaid.ts | 33 ++++++++ .../__tests__/translationParity.test.ts | 3 + .../src/popup/locales/en/translation.json | 3 + .../src/popup/locales/pt/translation.json | 3 + 7 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx create mode 100644 extension/src/popup/constants/__tests__/blockaid.test.ts diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx new file mode 100644 index 0000000000..692c12596f --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { RequestState } from "constants/request"; +import { SecurityLevel } from "popup/constants/blockaid"; +import { Wrapper } from "popup/__testHelpers__"; +import { ReviewTx } from "popup/components/InternalTransaction/ReviewTransaction"; + +const swapProps = { + assetIcon: null, + fee: "0.001", + sendAmount: "10", + sendPriceUsd: null, + srcAsset: "native", + networkDetails: { + network: "TESTNET", + networkName: "Test Net", + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: "https://horizon-testnet.stellar.org", + } as any, + title: "You are swapping", + onConfirm: jest.fn(), + onCancel: jest.fn(), + simulationState: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", dstAmountPriceUsd: "0", scanResult: null }, + error: null, + } as any, + dstAsset: { + icon: null, + canonical: "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + priceUsd: null, + amount: "25", + }, +}; + +const renderWithDestLevel = (securityLevel?: SecurityLevel) => + render( + + + , + ); + +describe("ReviewTx destination-token security gate", () => { + it("shows the destination-token warning and the Confirm-anyway gate when the token is malicious", () => { + renderWithDestLevel(SecurityLevel.MALICIOUS); + expect( + screen.getByTestId("review-tx-dest-token-warning"), + ).toBeInTheDocument(); + // Case-3 "Confirm anyway" gate renders the dedicated CancelAction button. + expect(screen.getByTestId("CancelAction")).toBeInTheDocument(); + }); + + it("shows the destination-token warning when the token is suspicious", () => { + renderWithDestLevel(SecurityLevel.SUSPICIOUS); + expect( + screen.getByTestId("review-tx-dest-token-warning"), + ).toBeInTheDocument(); + }); + + it("does not show a destination-token warning when the token is safe", () => { + renderWithDestLevel(SecurityLevel.SAFE); + expect( + screen.queryByTestId("review-tx-dest-token-warning"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 9ab153e74a..057ddbcceb 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -27,7 +27,7 @@ import { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; import { hardwareWalletTypeSelector } from "popup/ducks/accountServices"; import { MultiPaneSlider } from "popup/components/SlidingPaneSwitcher"; import { useValidateTransactionMemo } from "popup/helpers/useValidateTransactionMemo"; -import { SecurityLevel } from "popup/constants/blockaid"; +import { SecurityLevel, mergeSecurityLevels } from "popup/constants/blockaid"; import { useBlockaidOverrideState, useShouldTreatTxAsUnableToScan, @@ -115,6 +115,9 @@ interface ReviewTxProps { requiresTrustline: boolean; decimals: number; issuer?: string; + // Blockaid verdict captured when the destination token was picked; folded + // into the review security gate alongside the transaction scan (§4.1). + securityLevel?: SecurityLevel; } | null; } @@ -180,18 +183,44 @@ export const ReviewTx = ({ // Check override state (takes precedence, dev mode only) const blockaidOverrideState = useBlockaidOverrideState(); - // Determine security level (includes overrides - takes precedence on all panes) - const securityLevel = getTransactionSecurityLevel( + // Transaction-scan verdict (includes overrides - takes precedence on all panes) + const txSecurityLevel = getTransactionSecurityLevel( txScanResult, isUnableToScan, blockaidOverrideState, ); + // Roll the destination token's Blockaid verdict into the gate so a malicious / + // suspicious / unable-to-scan token warns and requires "Confirm anyway" — not + // only a flagged transaction (§4.1). Send passes no token level, so this + // reduces to the transaction verdict and leaves the Send gate unchanged. + const destTokenSecurityLevel = destinationTokenDetails?.securityLevel ?? null; + const securityLevel = mergeSecurityLevels([ + txSecurityLevel, + destTokenSecurityLevel, + ]); + const isMalicious = securityLevel === SecurityLevel.MALICIOUS; const isSuspicious = securityLevel === SecurityLevel.SUSPICIOUS; - // Determine if transaction warning should be shown - const shouldShowTxWarning = isMalicious || isSuspicious || isUnableToScan; + // Determine if a security warning should be shown (tx- or token-driven) + const shouldShowTxWarning = + isMalicious || + isSuspicious || + securityLevel === SecurityLevel.UNABLE_TO_SCAN; + + // Banner copy for a flagged destination token (null when the token is clean + // or its verdict is already covered by the transaction-scan banner). + const destTokenWarningMessage = + destTokenSecurityLevel === SecurityLevel.MALICIOUS + ? t("The token you're receiving was flagged as malicious by Blockaid.") + : destTokenSecurityLevel === SecurityLevel.SUSPICIOUS + ? t("The token you're receiving was flagged as suspicious by Blockaid.") + : destTokenSecurityLevel === SecurityLevel.UNABLE_TO_SCAN + ? t( + "The token you're receiving couldn't be scanned for security risks.", + ) + : null; /** * Pane state machine: @@ -350,7 +379,10 @@ export const ReviewTx = ({
- {shouldShowTxWarning && paneConfig.blockaidIndex !== null && ( + {/* Transaction-scan banner (opens the expandable Blockaid pane). Gated + on the tx verdict so a token-only warning doesn't open an empty + pane — the token verdict gets its own banner below. */} + {txSecurityLevel && paneConfig.blockaidIndex !== null && ( { @@ -360,6 +392,17 @@ export const ReviewTx = ({ }} /> )} + {destTokenWarningMessage && ( +
+ +
+ )} {isRequiredMemoMissing && !isValidatingMemo && !shouldShowTxWarning && ( setActivePaneIndex(paneConfig.memoIndex)} diff --git a/extension/src/popup/constants/__tests__/blockaid.test.ts b/extension/src/popup/constants/__tests__/blockaid.test.ts new file mode 100644 index 0000000000..c49f6a7959 --- /dev/null +++ b/extension/src/popup/constants/__tests__/blockaid.test.ts @@ -0,0 +1,42 @@ +import { SecurityLevel, mergeSecurityLevels } from "../blockaid"; + +describe("mergeSecurityLevels", () => { + it("returns null when nothing is flagged", () => { + expect( + mergeSecurityLevels([null, undefined, SecurityLevel.SAFE]), + ).toBeNull(); + }); + + it("returns the only flagged level", () => { + expect(mergeSecurityLevels([null, SecurityLevel.SUSPICIOUS])).toBe( + SecurityLevel.SUSPICIOUS, + ); + }); + + it("escalates to the most severe level (MALICIOUS > SUSPICIOUS)", () => { + expect( + mergeSecurityLevels([SecurityLevel.SUSPICIOUS, SecurityLevel.MALICIOUS]), + ).toBe(SecurityLevel.MALICIOUS); + }); + + it("ranks SUSPICIOUS above UNABLE_TO_SCAN", () => { + expect( + mergeSecurityLevels([ + SecurityLevel.UNABLE_TO_SCAN, + SecurityLevel.SUSPICIOUS, + ]), + ).toBe(SecurityLevel.SUSPICIOUS); + }); + + it("surfaces UNABLE_TO_SCAN when it is the only non-safe verdict", () => { + expect( + mergeSecurityLevels([SecurityLevel.SAFE, SecurityLevel.UNABLE_TO_SCAN]), + ).toBe(SecurityLevel.UNABLE_TO_SCAN); + }); + + it("never returns SAFE (a clean set is null, not SAFE)", () => { + expect( + mergeSecurityLevels([SecurityLevel.SAFE, SecurityLevel.SAFE]), + ).toBeNull(); + }); +}); diff --git a/extension/src/popup/constants/blockaid.ts b/extension/src/popup/constants/blockaid.ts index ca96890d93..b9e652ee49 100644 --- a/extension/src/popup/constants/blockaid.ts +++ b/extension/src/popup/constants/blockaid.ts @@ -8,3 +8,36 @@ export enum SecurityLevel { MALICIOUS = "MALICIOUS", UNABLE_TO_SCAN = "UNABLE_TO_SCAN", } + +// Severity ordering for rolling several verdicts into one. SAFE never warns. +const SECURITY_LEVEL_SEVERITY: Record = { + [SecurityLevel.SAFE]: 0, + [SecurityLevel.UNABLE_TO_SCAN]: 1, + [SecurityLevel.SUSPICIOUS]: 2, + [SecurityLevel.MALICIOUS]: 3, +}; + +/** + * Rolls multiple Blockaid verdicts (e.g. the transaction scan plus the source + * and destination token verdicts on a swap) into the single most severe level + * that warrants a warning. SAFE / null / undefined never escalate, so the + * result is null when nothing is flagged — matching getTransactionSecurityLevel, + * which returns null for a clean transaction (§4.1). + */ +export const mergeSecurityLevels = ( + levels: (SecurityLevel | null | undefined)[], +): SecurityLevel | null => { + let worst: SecurityLevel | null = null; + for (const level of levels) { + if (!level || level === SecurityLevel.SAFE) { + continue; + } + if ( + worst === null || + SECURITY_LEVEL_SEVERITY[level] > SECURITY_LEVEL_SEVERITY[worst] + ) { + worst = level; + } + } + return worst; +}; diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 500299e5b6..8f6bad6120 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -11,6 +11,9 @@ const swapKeys = [ "Insufficient balance", "Not enough XLM for network fees", "No quote available", + "The token you're receiving was flagged as malicious by Blockaid.", + "The token you're receiving was flagged as suspicious by Blockaid.", + "The token you're receiving couldn't be scanned for security risks.", ]; describe("swap i18n parity", () => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 826696dcc2..01ebf4b8d4 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -614,6 +614,9 @@ "The destination account must opt to accept this asset before receiving it.": "The destination account must opt to accept this asset before receiving it.", "The requester expects you to sign this message on": "The requester expects you to sign this message on", "The secret phrase you entered is incorrect.": "The secret phrase you entered is incorrect.", + "The token you're receiving couldn't be scanned for security risks.": "The token you're receiving couldn't be scanned for security risks.", + "The token you're receiving was flagged as malicious by Blockaid.": "The token you're receiving was flagged as malicious by Blockaid.", + "The token you're receiving was flagged as suspicious by Blockaid.": "The token you're receiving was flagged as suspicious by Blockaid.", "The token you’re trying to add is on": "The token you’re trying to add is on", "The transaction you’re trying to sign is on": "The transaction you’re trying to sign is on", "The website <1>{url} does not use an SSL certificate.": "The website <1>{url} does not use an SSL certificate.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 588526b96e..37ed844d24 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -614,6 +614,9 @@ "The destination account must opt to accept this asset before receiving it.": "A conta de destino deve optar por aceitar este ativo antes de recebê-lo.", "The requester expects you to sign this message on": "O solicitante espera que você assine esta mensagem em", "The secret phrase you entered is incorrect.": "A frase secreta que você inseriu está incorreta.", + "The token you're receiving couldn't be scanned for security risks.": "Não foi possível verificar riscos de segurança do token que você vai receber.", + "The token you're receiving was flagged as malicious by Blockaid.": "O token que você vai receber foi sinalizado como malicioso pela Blockaid.", + "The token you're receiving was flagged as suspicious by Blockaid.": "O token que você vai receber foi sinalizado como suspeito pela Blockaid.", "The token you’re trying to add is on": "O token que você está tentando adicionar está em", "The transaction you’re trying to sign is on": "A transação que você está tentando assinar está em", "The website <1>{url} does not use an SSL certificate.": "O site <1>{url} não usa um certificado SSL.", From 6450d41b268d8956759210757cac8fd26662748b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sat, 27 Jun 2026 13:53:53 -0300 Subject: [PATCH 087/121] Wire the swap source token's Blockaid verdict into the review gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/ReviewTx.security.test.tsx | 35 +++++++++++++++++- .../ReviewTransaction/index.tsx | 37 ++++++++++++++++++- .../components/swap/SwapAmount/index.tsx | 29 ++++++++++++++- extension/src/popup/helpers/blockaid.ts | 33 +++++++++++++++++ .../__tests__/translationParity.test.ts | 3 ++ .../src/popup/locales/en/translation.json | 3 ++ .../src/popup/locales/pt/translation.json | 3 ++ 7 files changed, 139 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx index 692c12596f..6972013aff 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx @@ -34,22 +34,32 @@ const swapProps = { }, }; -const renderWithDestLevel = (securityLevel?: SecurityLevel) => +const renderReview = ({ + destLevel, + sourceLevel, +}: { + destLevel?: SecurityLevel; + sourceLevel?: SecurityLevel; +}) => render( , ); +const renderWithDestLevel = (destLevel?: SecurityLevel) => + renderReview({ destLevel }); + describe("ReviewTx destination-token security gate", () => { it("shows the destination-token warning and the Confirm-anyway gate when the token is malicious", () => { renderWithDestLevel(SecurityLevel.MALICIOUS); @@ -73,4 +83,25 @@ describe("ReviewTx destination-token security gate", () => { screen.queryByTestId("review-tx-dest-token-warning"), ).not.toBeInTheDocument(); }); + + it("shows the source-token warning + Confirm-anyway gate when the sell token is malicious", () => { + renderReview({ sourceLevel: SecurityLevel.MALICIOUS }); + expect( + screen.getByTestId("review-tx-source-token-warning"), + ).toBeInTheDocument(); + expect(screen.getByTestId("CancelAction")).toBeInTheDocument(); + }); + + it("warns for both sides independently", () => { + renderReview({ + sourceLevel: SecurityLevel.SUSPICIOUS, + destLevel: SecurityLevel.MALICIOUS, + }); + expect( + screen.getByTestId("review-tx-source-token-warning"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("review-tx-dest-token-warning"), + ).toBeInTheDocument(); + }); }); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 057ddbcceb..d3ac89f06c 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -119,6 +119,9 @@ interface ReviewTxProps { // into the review security gate alongside the transaction scan (§4.1). securityLevel?: SecurityLevel; } | null; + // Blockaid verdict for the swap source token (from its held balance); folded + // into the same review gate so a flagged sell token also warns (§4.3). + sourceTokenSecurityLevel?: SecurityLevel; } export const ReviewTx = ({ @@ -135,6 +138,7 @@ export const ReviewTx = ({ onCancel, onAddMemo, destinationTokenDetails, + sourceTokenSecurityLevel, }: ReviewTxProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -197,6 +201,7 @@ export const ReviewTx = ({ const destTokenSecurityLevel = destinationTokenDetails?.securityLevel ?? null; const securityLevel = mergeSecurityLevels([ txSecurityLevel, + sourceTokenSecurityLevel ?? null, destTokenSecurityLevel, ]); @@ -222,6 +227,17 @@ export const ReviewTx = ({ ) : null; + const sourceTokenWarningMessage = + sourceTokenSecurityLevel === SecurityLevel.MALICIOUS + ? t("The token you're sending was flagged as malicious by Blockaid.") + : sourceTokenSecurityLevel === SecurityLevel.SUSPICIOUS + ? t("The token you're sending was flagged as suspicious by Blockaid.") + : sourceTokenSecurityLevel === SecurityLevel.UNABLE_TO_SCAN + ? t( + "The token you're sending couldn't be scanned for security risks.", + ) + : null; + /** * Pane state machine: * - No warning: [Review (0), Memo (1), Fees (2)] @@ -392,13 +408,32 @@ export const ReviewTx = ({ }} /> )} + {sourceTokenWarningMessage && ( +
+ +
+ )} {destTokenWarningMessage && (
diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index aa196a76bc..b827e7821e 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -53,7 +53,14 @@ import { openTab } from "popup/helpers/navigate"; import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; import { getAssetDecimals, getAvailableBalance } from "popup/helpers/soroban"; -import { getBalanceCanonicalKey } from "popup/helpers/balance"; +import { + findAssetBalance, + getBalanceCanonicalKey, +} from "popup/helpers/balance"; +import { + getAssetSecurityLevel, + useBlockaidOverrideState, +} from "popup/helpers/blockaid"; import { AppDispatch } from "popup/App"; import { emitMetric } from "helpers/metrics"; import { AMOUNT_ERROR, InputType } from "helpers/transaction"; @@ -115,6 +122,7 @@ export const SwapAmount = ({ const { networkCongestion, recommendedFee } = useNetworkFees(); const networkDetails = useSelector(settingsNetworkDetailsSelector); const publicKey = useSelector(publicKeySelector); + const blockaidOverrideState = useBlockaidOverrideState(); const { transactionData, isSwapQuoteExpired } = useSelector( transactionSubmissionSelector, ); @@ -549,6 +557,24 @@ export const SwapAmount = ({ // (§3.2). The sell side is the current source when it's already a non-XLM // classic token; otherwise the largest held non-XLM classic balance. const sourceIsNonXlmClassic = !!asset && asset !== "native"; + + // Source token Blockaid verdict (from its held balance), passed to the review + // gate so a flagged sell token also warns (§4.3). XLM is never scanned. + const sourceBalance = sourceIsNonXlmClassic + ? findAssetBalance( + sendData.userBalances.balances, + getAssetFromCanonical(asset), + ) + : null; + const sourceTokenSecurityLevel = + sourceBalance && "blockaidData" in sourceBalance + ? getAssetSecurityLevel({ + blockaidData: sourceBalance.blockaidData, + blockaidOverrideState, + networkDetails, + }) + : undefined; + // Plain computation (not useMemo): this runs below early returns, so a hook // here would violate the rules of hooks, and the filter/sort is cheap. const bestNonXlmClassicCanonical = pickBestNonXlmClassicCanonical( @@ -992,6 +1018,7 @@ export const SwapAmount = ({ }} title={t("You are swapping")} destinationTokenDetails={transactionData.destinationTokenDetails} + sourceTokenSecurityLevel={sourceTokenSecurityLevel} /> ) : ( <> diff --git a/extension/src/popup/helpers/blockaid.ts b/extension/src/popup/helpers/blockaid.ts index 13f6bb5c24..ad21fe46d8 100644 --- a/extension/src/popup/helpers/blockaid.ts +++ b/extension/src/popup/helpers/blockaid.ts @@ -443,6 +443,39 @@ export const isAssetSuspicious = ( return blockaidData!.result_type !== "Benign"; }; +/** + * Collapses a token's Blockaid scan result into a single SecurityLevel, + * honoring the dev override and the network gate. Used to derive the source + * token's verdict for the swap review gate (§4.3). UNABLE_TO_SCAN only applies + * where Blockaid runs (mainnet); a clean scan returns SAFE. + */ +export const getAssetSecurityLevel = ({ + blockaidData, + blockaidOverrideState, + networkDetails, +}: { + blockaidData?: BlockAidScanAssetResult | null; + blockaidOverrideState?: string | null; + networkDetails: NetworkDetails; +}): SecurityLevel => { + if (isAssetMalicious(blockaidData, blockaidOverrideState)) { + return SecurityLevel.MALICIOUS; + } + if (isAssetSuspicious(blockaidData, blockaidOverrideState)) { + return SecurityLevel.SUSPICIOUS; + } + if ( + shouldTreatAssetAsUnableToScan( + blockaidData, + blockaidOverrideState, + networkDetails, + ) + ) { + return SecurityLevel.UNABLE_TO_SCAN; + } + return SecurityLevel.SAFE; +}; + export const isTxSuspicious = ( blockaidData?: BlockAidScanTxResult | null, blockaidOverrideState?: string | null, diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 8f6bad6120..cbd2630b8e 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -14,6 +14,9 @@ const swapKeys = [ "The token you're receiving was flagged as malicious by Blockaid.", "The token you're receiving was flagged as suspicious by Blockaid.", "The token you're receiving couldn't be scanned for security risks.", + "The token you're sending was flagged as malicious by Blockaid.", + "The token you're sending was flagged as suspicious by Blockaid.", + "The token you're sending couldn't be scanned for security risks.", ]; describe("swap i18n parity", () => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 01ebf4b8d4..13f9e39029 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -617,6 +617,9 @@ "The token you're receiving couldn't be scanned for security risks.": "The token you're receiving couldn't be scanned for security risks.", "The token you're receiving was flagged as malicious by Blockaid.": "The token you're receiving was flagged as malicious by Blockaid.", "The token you're receiving was flagged as suspicious by Blockaid.": "The token you're receiving was flagged as suspicious by Blockaid.", + "The token you're sending couldn't be scanned for security risks.": "The token you're sending couldn't be scanned for security risks.", + "The token you're sending was flagged as malicious by Blockaid.": "The token you're sending was flagged as malicious by Blockaid.", + "The token you're sending was flagged as suspicious by Blockaid.": "The token you're sending was flagged as suspicious by Blockaid.", "The token you’re trying to add is on": "The token you’re trying to add is on", "The transaction you’re trying to sign is on": "The transaction you’re trying to sign is on", "The website <1>{url} does not use an SSL certificate.": "The website <1>{url} does not use an SSL certificate.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 37ed844d24..6d7299f726 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -617,6 +617,9 @@ "The token you're receiving couldn't be scanned for security risks.": "Não foi possível verificar riscos de segurança do token que você vai receber.", "The token you're receiving was flagged as malicious by Blockaid.": "O token que você vai receber foi sinalizado como malicioso pela Blockaid.", "The token you're receiving was flagged as suspicious by Blockaid.": "O token que você vai receber foi sinalizado como suspeito pela Blockaid.", + "The token you're sending couldn't be scanned for security risks.": "Não foi possível verificar riscos de segurança do token que você vai enviar.", + "The token you're sending was flagged as malicious by Blockaid.": "O token que você vai enviar foi sinalizado como malicioso pela Blockaid.", + "The token you're sending was flagged as suspicious by Blockaid.": "O token que você vai enviar foi sinalizado como suspeito pela Blockaid.", "The token you’re trying to add is on": "O token que você está tentando adicionar está em", "The transaction you’re trying to sign is on": "A transação que você está tentando assinar está em", "The website <1>{url} does not use an SSL certificate.": "O site <1>{url} não usa um certificado SSL.", From 344583a868c9c7dcd62c80088c2c635f77711f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sat, 27 Jun 2026 13:58:52 -0300 Subject: [PATCH 088/121] Emit swap success + trustline-added telemetry post-confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hooks/useSubmitTxData.tsx | 36 ++++++++++++++++--- .../__tests__/SwapAmount.telemetry.test.tsx | 15 ++++---- .../components/swap/SwapAmount/index.tsx | 13 +++---- .../constants/__tests__/metricsNames.test.ts | 1 + extension/src/popup/constants/metricsNames.ts | 1 + 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx index ac48dc24f3..e65e851125 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx @@ -52,7 +52,16 @@ function useSubmitTxData({ }); const { - transactionData: { asset, destination, federationAddress }, + transactionData: { + asset, + destination, + federationAddress, + destinationAsset, + destinationAmount, + amount, + allowedSlippage, + destinationTokenDetails, + }, transactionSimulation, } = submission; const sourceAsset = getAssetFromCanonical(asset); @@ -89,7 +98,24 @@ function useSubmitTxData({ ); if (submitFreighterTransaction.fulfilled.match(submitResp)) { - if (!isSwap) { + if (isSwap) { + // Post-confirmation swap telemetry (§3.8): the swap actually settled. + emitMetric(METRIC_NAMES.swapSuccess, { + sourceToken: sourceAsset.code, + destToken: destinationAsset, + sourceAmount: amount, + destAmount: destinationAmount, + allowedSlippage, + }); + // Trustline added only once the combined changeTrust + + // pathPaymentStrictSend transaction confirmed it (§3.4). + if (destinationTokenDetails?.requiresTrustline) { + emitMetric(METRIC_NAMES.swapTrustlineAdded, { + tokenCode: destinationTokenDetails.tokenCode, + tokenIssuer: destinationTokenDetails.issuer, + }); + } + } else { const isSelfOwnedDestination = (allAccounts ?? []).some( (account) => account.publicKey === destination, ); @@ -99,10 +125,10 @@ function useSubmitTxData({ addRecentAddress({ address: federationAddress || destination }), ); } + emitMetric(METRIC_NAMES.sendPaymentSuccess, { + sourceAsset: sourceAsset.code, + }); } - emitMetric(METRIC_NAMES.sendPaymentSuccess, { - sourceAsset: sourceAsset.code, - }); // After successful submission, re-fetch balances and collectibles to get their latest values diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx index a6b04db0e5..795dc99367 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx @@ -162,7 +162,7 @@ describe("SwapAmount telemetry + quote-expired surfacing", () => { ).toBeUndefined(); }); - it("emits swapTrustlineAdded on confirm when destination requires a trustline", async () => { + it("does NOT emit swapTrustlineAdded at review time — it fires post-confirmation", async () => { jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ state: { state: RequestState.SUCCESS, @@ -196,14 +196,11 @@ describe("SwapAmount telemetry + quote-expired surfacing", () => { fireEvent.click(confirmBtn); }); - const trustlineCall = emitMetricMock.mock.calls.find( - (c) => c[0] === "swap: trustline added", - ); - expect(trustlineCall).toBeDefined(); - expect(trustlineCall![1]).toMatchObject({ - tokenCode: "AQUA", - tokenIssuer: "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", - }); + // The trustline-added metric now fires only once the swap settles + // (useSubmitTxData), not here at review/confirm time (§3.4). + expect( + emitMetricMock.mock.calls.find((c) => c[0] === "swap: trustline added"), + ).toBeUndefined(); expect(goToNext).toHaveBeenCalled(); }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index b827e7821e..2405200574 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -997,15 +997,10 @@ export const SwapAmount = ({ fee={fee} networkDetails={networkDetails} onCancel={() => setIsReviewingTx(false)} - onConfirm={() => { - if (transactionData.destinationTokenDetails?.requiresTrustline) { - emitMetric(METRIC_NAMES.swapTrustlineAdded, { - tokenCode: transactionData.destinationTokenDetails.tokenCode, - tokenIssuer: transactionData.destinationTokenDetails.issuer, - }); - } - goToNext(); - }} + // The trustline-added + swap-success metrics fire post-confirmation + // (in useSubmitTxData), once the swap actually settles — not here at + // review time (§3.4/§3.8). + onConfirm={goToNext} sendAmount={amount} sendPriceUsd={priceValueUsd} simulationState={simulationState} diff --git a/extension/src/popup/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts index 805df56fa6..d93bd5a0f0 100644 --- a/extension/src/popup/constants/__tests__/metricsNames.test.ts +++ b/extension/src/popup/constants/__tests__/metricsNames.test.ts @@ -11,5 +11,6 @@ describe("METRIC_NAMES swap-to-new-token events", () => { expect(METRIC_NAMES.swapTrustlineAdded).toBe("swap: trustline added"); expect(METRIC_NAMES.swapXlmReserveShown).toBe("swap: xlm reserve shown"); expect(METRIC_NAMES.swapQuoteExpired).toBe("swap: quote expired"); + expect(METRIC_NAMES.swapSuccess).toBe("swap: success"); }); }); diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 0bdafd881c..97574adccd 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -84,6 +84,7 @@ export const METRIC_NAMES = { swapTrustlineAdded: "swap: trustline added", swapXlmReserveShown: "swap: xlm reserve shown", swapQuoteExpired: "swap: quote expired", + swapSuccess: "swap: success", viewAddCollectibles: "loaded screen: add collectibles", viewSendCollectible: "loaded screen: send collectible", From 0a10d36a4791ab27eeba87d876cf86d882adde44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sat, 27 Jun 2026 14:09:59 -0300 Subject: [PATCH 089/121] Cache the last idle swap lookup in memory for instant repaint + stale-serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwapAsset/hooks/useSwapTokenLookup.ts | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 4fe990fcbb..e5346f2da1 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -399,6 +399,19 @@ const recordFromSearchResult = ( }; }; +// Module-scoped cache of the last successful IDLE (no search term) lookup per +// network. It survives component remounts within a popup session, so +// re-entering the picker repaints instantly instead of flashing a spinner +// (§1.10), and it's served as a fallback when a fresh idle fetch fails (§5.4) +// rather than dropping Popular to held-only. In-memory only — it dies on popup +// close; cross-session disk persistence (§5.3) is a separate concern. +const swapIdleResultCacheByNetwork = new Map(); + +/** Test-only: clear the module-scoped idle cache between tests. */ +export const resetSwapIdleCacheForTests = () => { + swapIdleResultCacheByNetwork.clear(); +}; + // ---- the hook ---- export const useSwapTokenLookup = () => { @@ -432,7 +445,18 @@ export const useSwapTokenLookup = () => { abortControllerRef.current = controller; const { signal } = controller; - dispatch({ type: "FETCH_DATA_START" }); + // On idle re-entry, repaint instantly from the last cached result and + // revalidate silently (no spinner). Search has no such cache — show the + // spinner while it loads (§1.10). + const isIdle = !searchTerm.trim(); + const cachedIdleResult = isIdle + ? swapIdleResultCacheByNetwork.get(networkDetails.network) + : undefined; + if (cachedIdleResult) { + dispatch({ type: "FETCH_DATA_SUCCESS", payload: cachedIdleResult }); + } else { + dispatch({ type: "FETCH_DATA_START" }); + } // Token discovery (Popular + search) only exists on Mainnet / Testnet. // Custom / Futurenet networks degrade to held-only (permanent fallback). @@ -584,13 +608,25 @@ export const useSwapTokenLookup = () => { } } } + + // Cache the fully-decorated idle result for instant repaint + stale-serve. + if (isIdle) { + swapIdleResultCacheByNetwork.set(networkDetails.network, payload); + } } catch (e) { if (signal.aborted) { // Cancelled — silently ignore (another call is already in flight) return; } - // Graceful fallback: stellar.expert or Blockaid unreachable → held-only captureException(`useSwapTokenLookup fallback - ${JSON.stringify(e)}`); + // Serve the last good idle result on a transient failure instead of + // dropping Popular to held-only (§5.4); fall back to held-only only when + // there's nothing cached. + if (cachedIdleResult) { + dispatch({ type: "FETCH_DATA_SUCCESS", payload: cachedIdleResult }); + return; + } + // Graceful fallback: stellar.expert or Blockaid unreachable → held-only dispatch({ type: "FETCH_DATA_SUCCESS", payload: buildSwapSections({ From 9382116b368fc26c92e68b37608809fd5fff10e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sat, 27 Jun 2026 15:57:59 -0300 Subject: [PATCH 090/121] Persist the swap top-tokens list across popup sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwapAsset/hooks/useSwapTokenLookup.ts | 34 ++++++++--- .../__tests__/swapPopularTokensCache.test.ts | 47 +++++++++++++++ .../popup/helpers/swapPopularTokensCache.ts | 58 +++++++++++++++++++ 3 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts create mode 100644 extension/src/popup/helpers/swapPopularTokensCache.ts diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index e5346f2da1..c91ddcd29f 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -15,6 +15,10 @@ import { isMainnet, getCanonicalFromAsset } from "helpers/stellar"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; import { SecurityLevel } from "popup/constants/blockaid"; import { searchAsset } from "popup/helpers/searchAsset"; +import { + getPersistedPopularTokens, + setPersistedPopularTokens, +} from "popup/helpers/swapPopularTokensCache"; import { splitVerifiedAssetCurrency } from "popup/helpers/assetList"; import { isContractId } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; @@ -519,18 +523,34 @@ export const useSwapTokenLookup = () => { verifiedAssets = split.verifiedAssets; unverifiedAssets = split.unverifiedAssets; } else { - // IDLE path: popular tokens (cached or fresh from stellar.expert) + // IDLE path: popular tokens. Cache layering (fastest first): + // Redux (in-session) → chrome.storage.local (cross-session, §5.3) → + // stellar.expert trending request. const cachedByNetwork = popularTokensSelector(store.getState()); const cached = cachedByNetwork[networkDetails.network]; const isFresh = cached && Date.now() - cached.updatedAt < POPULAR_TOKENS_STALE_MS; - popular = isFresh - ? cached.tokens - : await fetchTrendingAssets({ networkDetails, signal }); - - if (!isFresh && popular.length) { - reduxDispatch(savePopularTokens({ networkDetails, tokens: popular })); + if (isFresh) { + popular = cached.tokens; + } else { + // Disk-persisted trending survives popup close, avoiding the slow + // trending request on reopen. Falls through to the network when the + // persisted copy is absent or stale. + const persisted = await getPersistedPopularTokens( + networkDetails.network, + ); + if (persisted) { + popular = persisted; + } else { + popular = await fetchTrendingAssets({ networkDetails, signal }); + if (popular.length) { + reduxDispatch( + savePopularTokens({ networkDetails, tokens: popular }), + ); + await setPersistedPopularTokens(networkDetails.network, popular); + } + } } // Intersect popular with verified lists to compute the verified canonical set. diff --git a/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts b/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts new file mode 100644 index 0000000000..420da49bc8 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts @@ -0,0 +1,47 @@ +const mockStore = new Map(); + +jest.mock("background/helpers/dataStorageAccess", () => ({ + browserLocalStorage: {}, + dataStorageAccess: () => ({ + getItem: async (key: string) => mockStore.get(key), + setItem: async (key: string, value: any) => { + mockStore.set(key, value); + }, + }), +})); + +import { + getPersistedPopularTokens, + setPersistedPopularTokens, +} from "../swapPopularTokensCache"; +import { POPULAR_TOKENS_STALE_MS } from "popup/ducks/cache"; + +const tokens = [ + { code: "AQUA", issuer: "GBNZ", domain: null, volume7d: 5 }, +] as any; + +describe("swapPopularTokensCache", () => { + beforeEach(() => mockStore.clear()); + + it("round-trips a fresh write", async () => { + await setPersistedPopularTokens("PUBLIC", tokens); + expect(await getPersistedPopularTokens("PUBLIC")).toEqual(tokens); + }); + + it("returns null when nothing is persisted", async () => { + expect(await getPersistedPopularTokens("PUBLIC")).toBeNull(); + }); + + it("returns null when the entry is older than the staleness window", async () => { + mockStore.set("swap_top_tokens_PUBLIC", { + tokens, + updatedAt: Date.now() - POPULAR_TOKENS_STALE_MS - 1, + }); + expect(await getPersistedPopularTokens("PUBLIC")).toBeNull(); + }); + + it("is scoped per network", async () => { + await setPersistedPopularTokens("PUBLIC", tokens); + expect(await getPersistedPopularTokens("TESTNET")).toBeNull(); + }); +}); diff --git a/extension/src/popup/helpers/swapPopularTokensCache.ts b/extension/src/popup/helpers/swapPopularTokensCache.ts new file mode 100644 index 0000000000..3a543e74bc --- /dev/null +++ b/extension/src/popup/helpers/swapPopularTokensCache.ts @@ -0,0 +1,58 @@ +import { + dataStorageAccess, + browserLocalStorage, +} from "background/helpers/dataStorageAccess"; +import { POPULAR_TOKENS_STALE_MS } from "popup/ducks/cache"; +import { TrendingAsset } from "popup/helpers/trendingAssets"; + +const localStore = dataStorageAccess(browserLocalStorage); + +const storageKey = (network: string) => `swap_top_tokens_${network}`; + +interface PersistedPopularTokens { + tokens: TrendingAsset[]; + updatedAt: number; +} + +/** + * Disk-backed (chrome.storage.local) cache of the stellar.expert top-tokens + * (trending) list, keyed per network. Unlike the in-memory Redux + module + * caches, it survives popup close — so reopening the extension paints Popular + * from disk instead of re-running the slow, server-computed trending request + * (§5.3). Uses the same 30-min staleness window as the Redux cache and returns + * null when the entry is absent or stale, so the caller fetches fresh. + * Best-effort: any storage error degrades to a network fetch. + */ +export const getPersistedPopularTokens = async ( + network: string, +): Promise => { + try { + const cached: PersistedPopularTokens | undefined = await localStore.getItem( + storageKey(network), + ); + if ( + !cached?.tokens?.length || + typeof cached.updatedAt !== "number" || + Date.now() - cached.updatedAt >= POPULAR_TOKENS_STALE_MS + ) { + return null; + } + return cached.tokens; + } catch (e) { + return null; + } +}; + +export const setPersistedPopularTokens = async ( + network: string, + tokens: TrendingAsset[], +): Promise => { + try { + await localStore.setItem(storageKey(network), { + tokens, + updatedAt: Date.now(), + } as PersistedPopularTokens); + } catch (e) { + // Best-effort: a write failure just means we re-fetch next time. + } +}; From 92e30aa7d4fc5787ebf33c54accc1ba4410863fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sat, 27 Jun 2026 16:09:40 -0300 Subject: [PATCH 091/121] Pre-warm the swap top-tokens cache from the account screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/useSwapTopTokensPrewarm.test.ts | 98 +++++++++++++++++++ .../popup/helpers/useSwapTopTokensPrewarm.ts | 78 +++++++++++++++ extension/src/popup/views/Account/index.tsx | 5 + 3 files changed, 181 insertions(+) create mode 100644 extension/src/popup/helpers/__tests__/useSwapTopTokensPrewarm.test.ts create mode 100644 extension/src/popup/helpers/useSwapTopTokensPrewarm.ts diff --git a/extension/src/popup/helpers/__tests__/useSwapTopTokensPrewarm.test.ts b/extension/src/popup/helpers/__tests__/useSwapTopTokensPrewarm.test.ts new file mode 100644 index 0000000000..eba7a7ae04 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/useSwapTopTokensPrewarm.test.ts @@ -0,0 +1,98 @@ +import { + MAINNET_NETWORK_DETAILS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; +import { prewarmTopTokens } from "../useSwapTopTokensPrewarm"; +import * as Trending from "popup/helpers/trendingAssets"; +import * as Cache from "popup/helpers/swapPopularTokensCache"; + +const tokens = [ + { code: "AQUA", issuer: "GBNZ", domain: null, volume7d: 5 }, +] as any; + +describe("prewarmTopTokens", () => { + afterEach(() => jest.restoreAllMocks()); + + it("fetches + persists + caches on mainnet when the disk cache is cold", async () => { + jest.spyOn(Cache, "getPersistedPopularTokens").mockResolvedValue(null); + const setSpy = jest + .spyOn(Cache, "setPersistedPopularTokens") + .mockResolvedValue(); + const fetchSpy = jest + .spyOn(Trending, "fetchTrendingAssets") + .mockResolvedValue(tokens); + const dispatch = jest.fn(); + + await prewarmTopTokens({ + networkDetails: MAINNET_NETWORK_DETAILS, + dispatch: dispatch as any, + }); + + expect(fetchSpy).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalled(); + expect(setSpy).toHaveBeenCalledWith( + MAINNET_NETWORK_DETAILS.network, + tokens, + ); + }); + + it("does nothing on testnet", async () => { + const fetchSpy = jest.spyOn(Trending, "fetchTrendingAssets"); + const dispatch = jest.fn(); + + await prewarmTopTokens({ + networkDetails: TESTNET_NETWORK_DETAILS, + dispatch: dispatch as any, + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("skips the network when the disk cache is already fresh", async () => { + jest.spyOn(Cache, "getPersistedPopularTokens").mockResolvedValue(tokens); + const fetchSpy = jest.spyOn(Trending, "fetchTrendingAssets"); + const dispatch = jest.fn(); + + await prewarmTopTokens({ + networkDetails: MAINNET_NETWORK_DETAILS, + dispatch: dispatch as any, + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("does not persist when the fetch returns nothing", async () => { + jest.spyOn(Cache, "getPersistedPopularTokens").mockResolvedValue(null); + const setSpy = jest + .spyOn(Cache, "setPersistedPopularTokens") + .mockResolvedValue(); + jest.spyOn(Trending, "fetchTrendingAssets").mockResolvedValue([]); + const dispatch = jest.fn(); + + await prewarmTopTokens({ + networkDetails: MAINNET_NETWORK_DETAILS, + dispatch: dispatch as any, + }); + + expect(dispatch).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + }); + + it("swallows fetch errors (best-effort)", async () => { + jest.spyOn(Cache, "getPersistedPopularTokens").mockResolvedValue(null); + jest + .spyOn(Trending, "fetchTrendingAssets") + .mockRejectedValue(new Error("network down")); + const dispatch = jest.fn(); + + await expect( + prewarmTopTokens({ + networkDetails: MAINNET_NETWORK_DETAILS, + dispatch: dispatch as any, + }), + ).resolves.toBeUndefined(); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/helpers/useSwapTopTokensPrewarm.ts b/extension/src/popup/helpers/useSwapTopTokensPrewarm.ts new file mode 100644 index 0000000000..47d4e04213 --- /dev/null +++ b/extension/src/popup/helpers/useSwapTopTokensPrewarm.ts @@ -0,0 +1,78 @@ +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { isMainnet } from "helpers/stellar"; +import { AppDispatch } from "popup/App"; +import { savePopularTokens } from "popup/ducks/cache"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { fetchTrendingAssets } from "popup/helpers/trendingAssets"; +import { + getPersistedPopularTokens, + setPersistedPopularTokens, +} from "popup/helpers/swapPopularTokensCache"; + +// Defer the pre-warm past the account screen's first paint + critical-path +// data fetches so it never competes for the main render. +const PREWARM_DELAY_MS = 1000; + +/** + * Best-effort top-tokens pre-warm (§5.7). Mainnet-only (trending is meaningless + * on testnet); skips the network when the persisted cache is still fresh, so it + * costs at most one trending request per staleness window. On a fresh fetch it + * updates both the Redux cache and the disk cache. All errors are swallowed — + * the Swap pipeline fetches on open if this didn't run. + */ +export const prewarmTopTokens = async ({ + networkDetails, + dispatch, + signal, +}: { + networkDetails: NetworkDetails; + dispatch: AppDispatch; + signal?: AbortSignal; +}): Promise => { + if (!isMainnet(networkDetails)) { + return; + } + try { + const persisted = await getPersistedPopularTokens(networkDetails.network); + if (persisted || signal?.aborted) { + return; + } + const tokens = await fetchTrendingAssets({ networkDetails, signal }); + if (signal?.aborted || !tokens.length) { + return; + } + dispatch(savePopularTokens({ networkDetails, tokens })); + await setPersistedPopularTokens(networkDetails.network, tokens); + } catch (e) { + // Best-effort: the Swap pipeline retries on open. + } +}; + +/** + * Mounts the top-tokens pre-warm on the account/home screen so the first Swap + * entry can paint Popular instantly. Runs once per mount, deferred past first + * paint, and aborts on unmount. + */ +export const useSwapTopTokensPrewarm = () => { + const dispatch = useDispatch(); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + + useEffect(() => { + const controller = new AbortController(); + const timer = setTimeout(() => { + void prewarmTopTokens({ + networkDetails, + dispatch, + signal: controller.signal, + }); + }, PREWARM_DELAY_MS); + + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [networkDetails, dispatch]); +}; diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index d6a61f9e58..9418c078e6 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -15,6 +15,7 @@ import { accountNameSelector } from "popup/ducks/accountServices"; import { openTab } from "popup/helpers/navigate"; import { isFullscreenMode } from "popup/helpers/isFullscreenMode"; import { isMainnet } from "helpers/stellar"; +import { useSwapTopTokensPrewarm } from "popup/helpers/useSwapTopTokensPrewarm"; import { AccountAssets } from "popup/components/account/AccountAssets"; import { AccountCollectibles } from "popup/components/account/AccountCollectibles"; @@ -77,6 +78,10 @@ export const Account = () => { const { refreshHiddenCollectibles, isCollectibleHidden } = useHiddenCollectibles(); + // Warm the swap top-tokens cache in the background so the first Swap entry + // paints Popular instantly (§5.7); no-op on testnet / when already cached. + useSwapTopTokensPrewarm(); + const previousAccountBalancesRef = useRef(null); const sorobanErrorShownRef = useRef(false); From 5a23b487d7b869e597a86dc4c53b195f74218d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sun, 28 Jun 2026 18:04:39 -0300 Subject: [PATCH 092/121] Route swap top-tokens persistence through the background message handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- @shared/api/internal.ts | 32 +++++++++ @shared/api/types/message-request.ts | 14 ++++ @shared/api/types/types.ts | 1 + @shared/constants/services.ts | 2 + .../__tests__/swapTopTokensCache.test.ts | 65 +++++++++++++++++++ .../handlers/cacheSwapTopTokens.ts | 18 +++++ .../handlers/getCachedSwapTopTokens.ts | 19 ++++++ .../messageListener/popupMessageListener.ts | 11 +++- extension/src/constants/localStorageTypes.ts | 1 + .../__tests__/swapPopularTokensCache.test.ts | 51 ++++++++++----- .../popup/helpers/swapPopularTokensCache.ts | 41 +++++------- 11 files changed, 209 insertions(+), 46 deletions(-) create mode 100644 extension/src/background/messageListener/__tests__/swapTopTokensCache.test.ts create mode 100644 extension/src/background/messageListener/handlers/cacheSwapTopTokens.ts create mode 100644 extension/src/background/messageListener/handlers/getCachedSwapTopTokens.ts diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index e2180b6a63..7f06618e02 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2514,3 +2514,35 @@ export const dismissDiscoverWelcome = async (): Promise => { return !!hasSeenDiscoverWelcome; }; + +export const getCachedSwapTopTokens = async ( + network: string, +): Promise<{ tokens: unknown[]; updatedAt: number } | null> => { + const { cachedSwapTopTokens, error } = await sendMessageToBackground({ + activePublicKey: null, + type: SERVICE_TYPES.GET_CACHED_SWAP_TOP_TOKENS, + network, + }); + + if (error) { + throw new Error(error); + } + + return cachedSwapTopTokens || null; +}; + +export const cacheSwapTopTokens = async ( + network: string, + tokens: unknown[], +): Promise => { + const { error } = await sendMessageToBackground({ + activePublicKey: null, + type: SERVICE_TYPES.CACHE_SWAP_TOP_TOKENS, + network, + tokens, + }); + + if (error) { + throw new Error(error); + } +}; diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 8856367d1a..feb0679e0c 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -325,6 +325,18 @@ export interface CacheAssetIconMessage extends BaseMessage { iconUrl: string; } +export interface GetCachedSwapTopTokensMessage extends BaseMessage { + type: SERVICE_TYPES.GET_CACHED_SWAP_TOP_TOKENS; + network: string; +} + +export interface CacheSwapTopTokensMessage extends BaseMessage { + type: SERVICE_TYPES.CACHE_SWAP_TOP_TOKENS; + network: string; + // Opaque to the background — the popup owns the trending-asset schema. + tokens: unknown[]; +} + export interface GetCachedDomainMessage extends BaseMessage { type: SERVICE_TYPES.GET_CACHED_ASSET_DOMAIN; assetCanonical: string; @@ -524,6 +536,8 @@ export type ServiceMessageRequest = | GetCachedAssetIconListMessage | GetCachedAssetIconMessage | CacheAssetIconMessage + | GetCachedSwapTopTokensMessage + | CacheSwapTopTokensMessage | GetCachedDomainMessage | CacheDomainMessage | GetMemoRequiredAccountsMessage diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index 73fb2bad88..355dc25921 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -130,6 +130,7 @@ export interface Response { overriddenBlockaidResponse: string | null; recentProtocols: RecentProtocolEntry[]; hasSeenDiscoverWelcome: boolean; + cachedSwapTopTokens: { tokens: unknown[]; updatedAt: number } | null; } export interface MemoRequiredAccount { diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 507df44dbb..44eb494bf3 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -70,6 +70,8 @@ export enum SERVICE_TYPES { CLEAR_RECENT_PROTOCOLS = "CLEAR_RECENT_PROTOCOLS", GET_DISCOVER_WELCOME_SEEN = "GET_DISCOVER_WELCOME_SEEN", DISMISS_DISCOVER_WELCOME = "DISMISS_DISCOVER_WELCOME", + GET_CACHED_SWAP_TOP_TOKENS = "GET_CACHED_SWAP_TOP_TOKENS", + CACHE_SWAP_TOP_TOKENS = "CACHE_SWAP_TOP_TOKENS", USER_ACTIVITY = "USER_ACTIVITY", SESSION_LOCKED = "SESSION_LOCKED", SESSION_UNLOCKED = "SESSION_UNLOCKED", diff --git a/extension/src/background/messageListener/__tests__/swapTopTokensCache.test.ts b/extension/src/background/messageListener/__tests__/swapTopTokensCache.test.ts new file mode 100644 index 0000000000..86240c8377 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/swapTopTokensCache.test.ts @@ -0,0 +1,65 @@ +import { mockDataStorage } from "background/messageListener/helpers/test-helpers"; +import { CACHED_SWAP_TOP_TOKENS_ID } from "constants/localStorageTypes"; +import { SERVICE_TYPES } from "@shared/constants/services"; + +import { cacheSwapTopTokens } from "../handlers/cacheSwapTopTokens"; +import { getCachedSwapTopTokens } from "../handlers/getCachedSwapTopTokens"; + +const tokens = [{ code: "AQUA", issuer: "GBNZ", domain: null, volume7d: 5 }]; + +const cacheRequest = (network: string, t: unknown[]) => + ({ + type: SERVICE_TYPES.CACHE_SWAP_TOP_TOKENS, + network, + tokens: t, + activePublicKey: null, + }) as any; + +const getRequest = (network: string) => + ({ + type: SERVICE_TYPES.GET_CACHED_SWAP_TOP_TOKENS, + network, + activePublicKey: null, + }) as any; + +describe("swap top-tokens cache handlers", () => { + beforeEach(async () => { + await mockDataStorage.remove(CACHED_SWAP_TOP_TOKENS_ID); + }); + + it("caches tokens per network with a timestamp and reads them back", async () => { + await cacheSwapTopTokens({ + request: cacheRequest("PUBLIC", tokens), + localStore: mockDataStorage, + }); + + const { cachedSwapTopTokens } = await getCachedSwapTopTokens({ + request: getRequest("PUBLIC"), + localStore: mockDataStorage, + }); + + expect(cachedSwapTopTokens?.tokens).toEqual(tokens); + expect(typeof cachedSwapTopTokens?.updatedAt).toBe("number"); + }); + + it("returns null for a network with nothing cached", async () => { + const { cachedSwapTopTokens } = await getCachedSwapTopTokens({ + request: getRequest("PUBLIC"), + localStore: mockDataStorage, + }); + expect(cachedSwapTopTokens).toBeNull(); + }); + + it("scopes cached entries per network", async () => { + await cacheSwapTopTokens({ + request: cacheRequest("PUBLIC", tokens), + localStore: mockDataStorage, + }); + + const { cachedSwapTopTokens } = await getCachedSwapTopTokens({ + request: getRequest("TESTNET"), + localStore: mockDataStorage, + }); + expect(cachedSwapTopTokens).toBeNull(); + }); +}); diff --git a/extension/src/background/messageListener/handlers/cacheSwapTopTokens.ts b/extension/src/background/messageListener/handlers/cacheSwapTopTokens.ts new file mode 100644 index 0000000000..22dcbb9092 --- /dev/null +++ b/extension/src/background/messageListener/handlers/cacheSwapTopTokens.ts @@ -0,0 +1,18 @@ +import { CacheSwapTopTokensMessage } from "@shared/api/types/message-request"; +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { CACHED_SWAP_TOP_TOKENS_ID } from "constants/localStorageTypes"; + +export const cacheSwapTopTokens = async ({ + request, + localStore, +}: { + request: CacheSwapTopTokensMessage; + localStore: DataStorageAccess; +}) => { + const cache = (await localStore.getItem(CACHED_SWAP_TOP_TOKENS_ID)) || {}; + cache[request.network] = { + tokens: request.tokens, + updatedAt: Date.now(), + }; + await localStore.setItem(CACHED_SWAP_TOP_TOKENS_ID, cache); +}; diff --git a/extension/src/background/messageListener/handlers/getCachedSwapTopTokens.ts b/extension/src/background/messageListener/handlers/getCachedSwapTopTokens.ts new file mode 100644 index 0000000000..2d26d92c57 --- /dev/null +++ b/extension/src/background/messageListener/handlers/getCachedSwapTopTokens.ts @@ -0,0 +1,19 @@ +import { GetCachedSwapTopTokensMessage } from "@shared/api/types/message-request"; +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { CACHED_SWAP_TOP_TOKENS_ID } from "constants/localStorageTypes"; + +interface CachedSwapTopTokensEntry { + tokens: unknown[]; + updatedAt: number; +} + +export const getCachedSwapTopTokens = async ({ + request, + localStore, +}: { + request: GetCachedSwapTopTokensMessage; + localStore: DataStorageAccess; +}): Promise<{ cachedSwapTopTokens: CachedSwapTopTokensEntry | null }> => { + const cache = (await localStore.getItem(CACHED_SWAP_TOP_TOKENS_ID)) || {}; + return { cachedSwapTopTokens: cache[request.network] || null }; +}; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index b03fba7c31..fc4a15df9e 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -99,6 +99,8 @@ import { addRecentProtocol } from "./handlers/addRecentProtocol"; import { clearRecentProtocols } from "./handlers/clearRecentProtocols"; import { getDiscoverWelcomeSeen } from "./handlers/getDiscoverWelcomeSeen"; import { dismissDiscoverWelcome } from "./handlers/dismissDiscoverWelcome"; +import { getCachedSwapTopTokens } from "./handlers/getCachedSwapTopTokens"; +import { cacheSwapTopTokens } from "./handlers/cacheSwapTopTokens"; const numOfPublicKeysToCheck = 5; @@ -157,8 +159,7 @@ export const popupMessageListener = ( // (browser-action popup, sidepanel) OR the URL is on our extension // origin (popup window, options page, fullscreen). const extensionOrigin = browser?.runtime?.getURL?.("") ?? ""; - const isFromOwnExtension = - !sender.id || sender.id === browser?.runtime?.id; + const isFromOwnExtension = !sender.id || sender.id === browser?.runtime?.id; const isExtensionUrl = !!extensionOrigin && typeof sender.url === "string" && @@ -613,6 +614,12 @@ export const popupMessageListener = ( case SERVICE_TYPES.DISMISS_DISCOVER_WELCOME: { return dismissDiscoverWelcome({ localStore }); } + case SERVICE_TYPES.GET_CACHED_SWAP_TOP_TOKENS: { + return getCachedSwapTopTokens({ request, localStore }); + } + case SERVICE_TYPES.CACHE_SWAP_TOP_TOKENS: { + return cacheSwapTopTokens({ request, localStore }); + } case SERVICE_TYPES.MARK_QUEUE_ACTIVE: { const { uuid, isActive } = request as MarkQueueActiveMessage; if (isActive) { diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index 764180f147..831680317a 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -8,6 +8,7 @@ export const ACCOUNT_NAME_LIST_ID = "accountNameList"; export const CACHED_MEMO_REQUIRED_ACCOUNTS_ID = "cachedMemoRequiredAccountsId"; export const CACHED_ASSET_ICONS_ID = "cachedAssetIconsId"; export const CACHED_ASSET_DOMAINS_ID = "cachedAssetDomainsId"; +export const CACHED_SWAP_TOP_TOKENS_ID = "cachedSwapTopTokensId"; export const IS_VALIDATING_MEMO_ID = "isValidatingMemo"; export const IS_EXPERIMENTAL_MODE_ID = "isExperimentalMode"; export const RECENT_ADDRESSES = "recentAddresses"; diff --git a/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts b/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts index 420da49bc8..5f4fcb9e95 100644 --- a/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts +++ b/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts @@ -1,47 +1,62 @@ -const mockStore = new Map(); - -jest.mock("background/helpers/dataStorageAccess", () => ({ - browserLocalStorage: {}, - dataStorageAccess: () => ({ - getItem: async (key: string) => mockStore.get(key), - setItem: async (key: string, value: any) => { - mockStore.set(key, value); - }, - }), +jest.mock("@shared/api/internal", () => ({ + getCachedSwapTopTokens: jest.fn(), + cacheSwapTopTokens: jest.fn(), })); +import { + getCachedSwapTopTokens, + cacheSwapTopTokens, +} from "@shared/api/internal"; import { getPersistedPopularTokens, setPersistedPopularTokens, } from "../swapPopularTokensCache"; import { POPULAR_TOKENS_STALE_MS } from "popup/ducks/cache"; +const getMock = getCachedSwapTopTokens as jest.Mock; +const cacheMock = cacheSwapTopTokens as jest.Mock; + const tokens = [ { code: "AQUA", issuer: "GBNZ", domain: null, volume7d: 5 }, ] as any; describe("swapPopularTokensCache", () => { - beforeEach(() => mockStore.clear()); + beforeEach(() => jest.clearAllMocks()); - it("round-trips a fresh write", async () => { - await setPersistedPopularTokens("PUBLIC", tokens); + it("returns fresh tokens from the background cache", async () => { + getMock.mockResolvedValue({ tokens, updatedAt: Date.now() }); expect(await getPersistedPopularTokens("PUBLIC")).toEqual(tokens); + expect(getMock).toHaveBeenCalledWith("PUBLIC"); }); - it("returns null when nothing is persisted", async () => { + it("returns null when the background has nothing cached", async () => { + getMock.mockResolvedValue(null); expect(await getPersistedPopularTokens("PUBLIC")).toBeNull(); }); - it("returns null when the entry is older than the staleness window", async () => { - mockStore.set("swap_top_tokens_PUBLIC", { + it("returns null when the cached entry is older than the staleness window", async () => { + getMock.mockResolvedValue({ tokens, updatedAt: Date.now() - POPULAR_TOKENS_STALE_MS - 1, }); expect(await getPersistedPopularTokens("PUBLIC")).toBeNull(); }); - it("is scoped per network", async () => { + it("swallows a messaging error and returns null", async () => { + getMock.mockRejectedValue(new Error("messaging failed")); + expect(await getPersistedPopularTokens("PUBLIC")).toBeNull(); + }); + + it("writes through cacheSwapTopTokens", async () => { + cacheMock.mockResolvedValue(undefined); await setPersistedPopularTokens("PUBLIC", tokens); - expect(await getPersistedPopularTokens("TESTNET")).toBeNull(); + expect(cacheMock).toHaveBeenCalledWith("PUBLIC", tokens); + }); + + it("swallows a write error (best-effort)", async () => { + cacheMock.mockRejectedValue(new Error("write failed")); + await expect( + setPersistedPopularTokens("PUBLIC", tokens), + ).resolves.toBeUndefined(); }); }); diff --git a/extension/src/popup/helpers/swapPopularTokensCache.ts b/extension/src/popup/helpers/swapPopularTokensCache.ts index 3a543e74bc..234d1acb07 100644 --- a/extension/src/popup/helpers/swapPopularTokensCache.ts +++ b/extension/src/popup/helpers/swapPopularTokensCache.ts @@ -1,35 +1,27 @@ import { - dataStorageAccess, - browserLocalStorage, -} from "background/helpers/dataStorageAccess"; + cacheSwapTopTokens, + getCachedSwapTopTokens, +} from "@shared/api/internal"; import { POPULAR_TOKENS_STALE_MS } from "popup/ducks/cache"; import { TrendingAsset } from "popup/helpers/trendingAssets"; -const localStore = dataStorageAccess(browserLocalStorage); - -const storageKey = (network: string) => `swap_top_tokens_${network}`; - -interface PersistedPopularTokens { - tokens: TrendingAsset[]; - updatedAt: number; -} - /** * Disk-backed (chrome.storage.local) cache of the stellar.expert top-tokens - * (trending) list, keyed per network. Unlike the in-memory Redux + module - * caches, it survives popup close — so reopening the extension paints Popular - * from disk instead of re-running the slow, server-computed trending request - * (§5.3). Uses the same 30-min staleness window as the Redux cache and returns - * null when the entry is absent or stale, so the caller fetches fresh. - * Best-effort: any storage error degrades to a network fetch. + * (trending) list, keyed per network. The storage write itself lives in the + * background (the GET/CACHE_SWAP_TOP_TOKENS message handlers own + * chrome.storage.local, matching every other cache in the codebase); this + * popup-side wrapper just messages the background and applies the 30-min + * staleness window (§5.3). Unlike the in-memory Redux + module caches, it + * survives popup close, so reopening paints Popular from disk instead of + * re-running the slow trending request. Returns null when the entry is absent + * or stale so the caller fetches fresh. Best-effort: any messaging error + * degrades to a network fetch. */ export const getPersistedPopularTokens = async ( network: string, ): Promise => { try { - const cached: PersistedPopularTokens | undefined = await localStore.getItem( - storageKey(network), - ); + const cached = await getCachedSwapTopTokens(network); if ( !cached?.tokens?.length || typeof cached.updatedAt !== "number" || @@ -37,7 +29,7 @@ export const getPersistedPopularTokens = async ( ) { return null; } - return cached.tokens; + return cached.tokens as TrendingAsset[]; } catch (e) { return null; } @@ -48,10 +40,7 @@ export const setPersistedPopularTokens = async ( tokens: TrendingAsset[], ): Promise => { try { - await localStore.setItem(storageKey(network), { - tokens, - updatedAt: Date.now(), - } as PersistedPopularTokens); + await cacheSwapTopTokens(network, tokens); } catch (e) { // Best-effort: a write failure just means we re-fetch next time. } From d039de25ce6cbafaf63eae6325cfc9209f9bb933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sun, 28 Jun 2026 18:22:36 -0300 Subject: [PATCH 093/121] Improve the swap amount-screen default state + "Select a token" CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../helpers/__tests__/swapCtaState.test.ts | 6 +-- .../swap/SwapAmount/helpers/swapCtaState.ts | 5 +- .../components/swap/SwapAmount/index.tsx | 46 ++++++++++++++++--- .../__tests__/translationParity.test.ts | 1 + .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + .../src/popup/views/__tests__/Swap.test.tsx | 8 ++-- 7 files changed, 53 insertions(+), 15 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts index 38142e49e7..8705bc4754 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts @@ -10,13 +10,13 @@ const base: SwapCtaInputs = { }; describe("getSwapCtaState", () => { - it("prompts to select an asset when either side is missing", () => { + it("prompts to select a token (enabled, so it can open the picker) when either side is missing", () => { expect(getSwapCtaState({ ...base, hasSource: false })).toEqual({ - disabled: true, + disabled: false, labelKey: "select", }); expect(getSwapCtaState({ ...base, hasDestination: false })).toEqual({ - disabled: true, + disabled: false, labelKey: "select", }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts index bceca86741..74d105ba8b 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts @@ -27,8 +27,11 @@ export const getSwapCtaState = ({ insufficientXlmForFees, hasNoSwapPath, }: SwapCtaInputs): { disabled: boolean; labelKey: SwapCtaLabelKey } => { + // Enabled so the user can tap it to open the picker for the missing side + // (the screen prefers the sell token when both are missing). Other blocking + // states stay disabled — there's nothing useful to do from them. if (!hasSource || !hasDestination) { - return { disabled: true, labelKey: "select" }; + return { disabled: false, labelKey: "select" }; } if (amountIsZero) { return { disabled: true, labelKey: "enter" }; diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 2405200574..362341233f 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -677,7 +677,7 @@ export const SwapAmount = ({ hasNoSwapPath, }); const ctaLabels: Record = { - select: t("Select an asset"), + select: t("Select a token"), enter: t("Enter an amount"), insufficientBalance: t("Insufficient balance"), insufficientXlmFees: t("Not enough XLM for network fees"), @@ -743,6 +743,22 @@ export const SwapAmount = ({ disabled={cta.disabled} onClick={(e) => { e.preventDefault(); + // In the "select" state the button is a shortcut to the picker + // for the missing side — preferring the sell token when both + // are missing — rather than a submit (§ task 1). + if (cta.labelKey === "select") { + const side = !asset ? "source" : "destination"; + emitMetric(METRIC_NAMES.swapPickerOpened, { + side, + source: "cta", + }); + if (!asset) { + goToEditSrc(); + } else { + goToEditDst(); + } + return; + } formik.submitForm(); }} > @@ -774,9 +790,22 @@ export const SwapAmount = ({ availableBalanceText={availableBalanceText} availableBalanceFontSizePx={availableBalanceFontSizePx} inputType={inputType} - amount={formik.values.amount} - amountUsd={formik.values.amountUsd} + // Show the gray "0" placeholder (empty input) until an + // amount is entered; redux keeps the canonical "0". + amount={ + formik.values.amount === "0" ? "" : formik.values.amount + } + amountUsd={ + formik.values.amountUsd === "0.00" + ? "" + : formik.values.amountUsd + } amountFontSizeClass={getAmountFontSizeClass()} + // Don't grab focus until the swap is ready to receive an + // amount (both tokens picked); on entry the source defaults + // to XLM but the receive side is empty, so the card stays + // unfocused with a gray "0" placeholder (§ task 1). + autoFocus={!!asset && !!destinationAsset} assetCode={srcAsset ? srcAsset.code : ""} assetIcon={assetIcon} assetIcons={ @@ -798,12 +827,15 @@ export const SwapAmount = ({ isAmountTooHigh={isAmountTooHigh} cryptoDecimals={assetDecimals} onAmountChange={({ amount: newAmount }) => { - formik.setFieldValue("amount", newAmount); - dispatch(saveAmount(newAmount)); + // Normalize a cleared input back to the canonical "0". + const v = newAmount === "" ? "0" : newAmount; + formik.setFieldValue("amount", v); + dispatch(saveAmount(v)); }} onAmountUsdChange={({ amount: newAmount }) => { - formik.setFieldValue("amountUsd", newAmount); - dispatch(saveAmountUsd(newAmount)); + const v = newAmount === "" ? "0.00" : newAmount; + formik.setFieldValue("amountUsd", v); + dispatch(saveAmountUsd(v)); }} onToggleInputType={() => { const newInputType = diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index cbd2630b8e..cecd0cb2b0 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -6,6 +6,7 @@ const swapKeys = [ "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", "No tokens match {{term}}", + "Select a token", "You sell", "You receive", "Insufficient balance", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 13f9e39029..2ff033c3ae 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -517,6 +517,7 @@ "Security": "Security", "Select": "Select", "Select a hardware wallet you’d like to use with Freighter.": "Select a hardware wallet you’d like to use with Freighter.", + "Select a token": "Select a token", "Select an asset": "Select an asset", "Select asset": "Select asset", "Seller": "Seller", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 6d7299f726..1b6032cf2b 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -517,6 +517,7 @@ "Security": "Segurança", "Select": "Selecionar", "Select a hardware wallet you’d like to use with Freighter.": "Selecione uma carteira de hardware que você gostaria de usar com o Freighter.", + "Select a token": "Selecionar um token", "Select an asset": "Selecionar um ativo", "Select asset": "Selecionar ativo", "Seller": "Vendedor", diff --git a/extension/src/popup/views/__tests__/Swap.test.tsx b/extension/src/popup/views/__tests__/Swap.test.tsx index 26655a2526..fb33d71d02 100644 --- a/extension/src/popup/views/__tests__/Swap.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.test.tsx @@ -976,8 +976,8 @@ describe.skip("Swap", () => { }); }); - describe("Select an asset button disabled state", () => { - it("Select an asset button is disabled when no destination asset is selected", async () => { + describe("Select a token button state", () => { + it("Select a token button is enabled (opens the picker) when no destination token is selected", async () => { render( { }); const continueButton = screen.getByTestId("swap-amount-btn-continue"); - expect(continueButton).toBeDisabled(); - expect(continueButton).toHaveTextContent("Select an asset"); + expect(continueButton).toBeEnabled(); + expect(continueButton).toHaveTextContent("Select a token"); }); it("Button shows Review swap and is enabled when destination asset is selected and amount > 0", async () => { From 986a2ab568089ce812bf3e2b983698dcc40cd7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sun, 28 Jun 2026 18:34:44 -0300 Subject: [PATCH 094/121] Resolve a pasted SAC to the held token it wraps on the swap-from picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/matchesSwapFromSearch.test.ts | 74 +++++++++++++++++++ .../SwapAsset/hooks/matchesSwapFromSearch.ts | 60 +++++++++++++++ .../swap/SwapAsset/hooks/useSwapFromData.tsx | 26 +++---- 3 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAsset/hooks/__tests__/matchesSwapFromSearch.test.ts create mode 100644 extension/src/popup/components/swap/SwapAsset/hooks/matchesSwapFromSearch.ts diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/matchesSwapFromSearch.test.ts b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/matchesSwapFromSearch.test.ts new file mode 100644 index 0000000000..bd03e5dda3 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/matchesSwapFromSearch.test.ts @@ -0,0 +1,74 @@ +import { Networks } from "stellar-sdk"; + +import { + ClassicAsset, + NativeAsset, + SorobanAsset, +} from "@shared/api/types/account-balance"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { getAssetSacAddress } from "@shared/helpers/soroban/token"; +import { getNativeContractDetails } from "popup/helpers/searchAsset"; + +import { matchesSwapFromSearch } from "../matchesSwapFromSearch"; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +// An unrelated, valid Soroban contract id (neither the USDC SAC nor native). +const OTHER_CONTRACT = + "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7"; + +const usdcBalance = { + token: { + type: "credit_alphanum4", + code: "USDC", + issuer: { key: USDC_ISSUER }, + }, +} as unknown as ClassicAsset; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, +} as unknown as NativeAsset; + +const sorobanBalance = { + token: { code: "ABC", issuer: { key: USDC_ISSUER } }, + contractId: OTHER_CONTRACT, +} as unknown as SorobanAsset; + +const match = (balance: any, searchTerm: string) => + matchesSwapFromSearch({ + balance, + searchTerm, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + +describe("matchesSwapFromSearch", () => { + it("matches by token code (case-insensitive, partial)", () => { + expect(match(usdcBalance, "usd")).toBe(true); + expect(match(usdcBalance, "USDC")).toBe(true); + }); + + it("matches a classic asset by its issuer", () => { + expect(match(usdcBalance, USDC_ISSUER)).toBe(true); + }); + + it("matches a Soroban balance by its contractId", () => { + expect(match(sorobanBalance, OTHER_CONTRACT)).toBe(true); + }); + + it("resolves a pasted SAC to the held classic token it wraps", () => { + const usdcSac = getAssetSacAddress(`USDC:${USDC_ISSUER}`, Networks.TESTNET); + expect(match(usdcBalance, usdcSac)).toBe(true); + }); + + it("resolves the native SAC to the held XLM balance", () => { + const xlmSac = getNativeContractDetails(TESTNET_NETWORK_DETAILS).contract; + expect(match(nativeBalance, xlmSac)).toBe(true); + }); + + it("does not match an unrelated contract id to a held token", () => { + expect(match(usdcBalance, OTHER_CONTRACT)).toBe(false); + }); + + it("does not match an unrelated search term", () => { + expect(match(usdcBalance, "zzz")).toBe(false); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/matchesSwapFromSearch.ts b/extension/src/popup/components/swap/SwapAsset/hooks/matchesSwapFromSearch.ts new file mode 100644 index 0000000000..2b33f08ebc --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/matchesSwapFromSearch.ts @@ -0,0 +1,60 @@ +import { AssetType } from "@shared/api/types/account-balance"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { isAssetSac, isContractId } from "popup/helpers/soroban"; + +/** + * Whether a held balance matches the swap-from ("Swap from") search term. + * Matches by token code, classic issuer, or Soroban contractId; and — when the + * term is itself a contract id — by the held classic/native token's derived SAC + * address, so a pasted SAC resolves to the token it wraps without an API call + * (§ task 2). The destination picker gets the equivalent SAC match back from + * stellar.expert; this keeps the source picker symmetric. + */ +export const matchesSwapFromSearch = ({ + balance, + searchTerm, + networkDetails, +}: { + balance: AssetType; + searchTerm: string; + networkDetails: NetworkDetails; +}): boolean => { + const term = searchTerm.toLowerCase(); + const trimmed = searchTerm.trim(); + + if ("token" in balance && balance.token.code.toLowerCase().includes(term)) { + return true; + } + if ( + "token" in balance && + "issuer" in balance.token && + balance.token.issuer.key.toLowerCase().includes(term) + ) { + return true; + } + if ( + "contractId" in balance && + balance.contractId.toLowerCase().includes(term) + ) { + return true; + } + // SAC: derive the held token's SAC address (no API) and compare to a pasted + // contract id. Gated on the term being a contract id so the derivation only + // runs for that case. + if ( + isContractId(trimmed) && + "token" in balance && + isAssetSac({ + asset: { + code: balance.token.code, + issuer: + "issuer" in balance.token ? balance.token.issuer.key : undefined, + contract: trimmed, + }, + networkDetails, + }) + ) { + return true; + } + return false; +}; diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx index 47798b5223..5d649d8e26 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx @@ -4,6 +4,7 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { initialState, isError, reducer } from "helpers/request"; import { isMainnet } from "helpers/stellar"; +import { matchesSwapFromSearch } from "./matchesSwapFromSearch"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; import { @@ -105,22 +106,15 @@ export function useGetSwapFromData(getBalancesOptions: { const balances = resolvedSwapData?.balances.balances || []; // Filter from the first character (token codes can be 1-2 letters), matching // the destination ("Swap to") search. The empty-term case is handled above. - const filtered = balances.filter((balance) => { - if ("token" in balance && balance.token.code.toLowerCase().includes(term)) - return true; - if ( - "token" in balance && - "issuer" in balance.token && - balance.token.issuer.key.toLowerCase().includes(term) - ) - return true; - if ( - "contractId" in balance && - balance.contractId.toLowerCase().includes(term) - ) - return true; - return false; - }); + // matchesSwapFromSearch also resolves a pasted SAC to the held token it + // wraps (§ task 2) — derived from the asset, no extra API call. + const filtered = balances.filter((balance) => + matchesSwapFromSearch({ + balance, + searchTerm, + networkDetails: resolvedSwapData.networkDetails, + }), + ); const payload = { ...resolvedSwapData, filteredBalances: filtered, From 9c2a4b578a054190a2a0b945f4bfeb2b01b06dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sun, 28 Jun 2026 18:39:14 -0300 Subject: [PATCH 095/121] Keep the Blockaid warning badge on the selected swap token pills 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 --- .../SwapAmount.securityBadge.test.tsx | 106 ++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 10 ++ 2 files changed, 116 insertions(+) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.securityBadge.test.tsx diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.securityBadge.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.securityBadge.test.tsx new file mode 100644 index 0000000000..ca991acb85 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.securityBadge.test.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { SecurityLevel } from "popup/constants/blockaid"; +import { Wrapper } from "popup/__testHelpers__"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices: {}, +}; + +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + +const renderWithDestination = (securityLevel?: SecurityLevel) => + render( + + + , + ); + +describe("SwapAmount destination security badge", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", scanResult: null }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + }); + afterEach(() => jest.restoreAllMocks()); + + it("shows the warning badge on the receive card for a malicious destination", () => { + renderWithDestination(SecurityLevel.MALICIOUS); + const receiveCard = screen.getByTestId("swap-receive-card"); + expect( + within(receiveCard).getByTestId("ScamAssetIcon"), + ).toBeInTheDocument(); + }); + + it("does not show the badge for a safe destination", () => { + renderWithDestination(SecurityLevel.SAFE); + const receiveCard = screen.getByTestId("swap-receive-card"); + expect( + within(receiveCard).queryByTestId("ScamAssetIcon"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 362341233f..f1d8a4a00e 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -812,6 +812,10 @@ export const SwapAmount = ({ asset && asset !== "native" ? { [asset]: assetIcon } : {} } assetIssuerKey={srcAsset?.issuer} + // Carry the sell token's Blockaid verdict onto its pill so a + // flagged source keeps its warning badge after selection, + // matching the picker list (§ task 3). + securityLevel={sourceTokenSecurityLevel} supportsUsd={Boolean(supportsUsd)} fiatLineText={ !asset @@ -912,6 +916,12 @@ export const SwapAmount = ({ : {} } assetIssuerKey={dstAsset?.issuer} + // Carry the destination token's pick-time Blockaid verdict + // onto its pill so a flagged token keeps its warning badge + // after selection, matching the picker list (§ task 3). + securityLevel={ + transactionData.destinationTokenDetails?.securityLevel + } supportsUsd={Boolean(supportsUsd)} fiatLineText={ !destinationAsset From 512f58179f09ef442ff355440aad415e1e70ac80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sun, 28 Jun 2026 18:42:36 -0300 Subject: [PATCH 096/121] Show a loader while the swap-to search is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwapAsset/__tests__/SwapAsset.test.tsx | 45 +++++++++++++++++++ .../popup/components/swap/SwapAsset/index.tsx | 24 +++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx index 7686f8c221..601ce76dc4 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -148,6 +148,51 @@ describe("SwapAsset selectionType", () => { }); }); + it("destination: typing clears all results and shows the loader until the lookup settles", () => { + render( + + + , + ); + + // Idle results are shown first. + expect(screen.getByTestId("swap-picker-sections")).toBeInTheDocument(); + + // Typing immediately replaces every result (held + non-held) with the + // loader, instead of leaving stale held tokens visible during the debounce. + fireEvent.change(screen.getByTestId("swap-from-search"), { + target: { value: "AQ" }, + }); + + expect(screen.queryByTestId("swap-picker-sections")).toBeNull(); + expect(document.querySelector(".SwapFrom__loader")).toBeInTheDocument(); + }); + + it("source: typing does not show the loader (the filter is synchronous)", () => { + render( + + + , + ); + + fireEvent.change(screen.getByTestId("swap-from-search"), { + target: { value: "US" }, + }); + + expect(screen.getByTestId("swap-picker-sections")).toBeInTheDocument(); + expect(document.querySelector(".SwapFrom__loader")).toBeNull(); + }); + it("destination: runs the idle lookup with the account's held balances", () => { const heldUsdc = { token: { code: "USDC", issuer: { key: "GUSD" } }, diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index f11ee6c635..5bc9dab163 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -65,6 +65,22 @@ export const SwapAsset = ({ const { fetchData: lookupFetchData, state: lookupState } = useSwapTokenLookup(); + // Destination search: the previous lookup result lingers in lookupState + // during the 300ms debounce, so the picker would briefly show stale held + // tokens (and empty search sections) before the new results arrive. Track a + // pending flag from the moment the user types until the lookup settles so we + // show the loader instead — clearing every result at once (§ task 4). + const [isSearchPending, setIsSearchPending] = React.useState(false); + + React.useEffect(() => { + if ( + lookupState.state === RequestState.SUCCESS || + lookupState.state === RequestState.ERROR + ) { + setIsSearchPending(false); + } + }, [lookupState.state]); + const isLoading = isDestination ? lookupState.state === RequestState.IDLE || lookupState.state === RequestState.LOADING @@ -119,6 +135,12 @@ export const SwapAsset = ({ const handleChange = (e: React.ChangeEvent) => { const val = e.target.value; formik.setFieldValue("searchTerm", val); + // The destination lookup is async (debounced + network); show the loader + // until it settles so all results clear at once on each keystroke. The + // source filter is synchronous, so it doesn't need this. + if (isDestination) { + setIsSearchPending(true); + } debouncedSubmit(); }; @@ -252,7 +274,7 @@ export const SwapAsset = ({ />
- {isLoading ? ( + {isLoading || isSearchPending ? (
From e068684a703c9d5596d5ecf263930370e3299621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sun, 28 Jun 2026 18:48:25 -0300 Subject: [PATCH 097/121] Give the swap review Rate row a content-width label and a fitted value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/SwapRateRow.tsx | 21 ++++++- .../components/__tests__/SwapRateRow.test.tsx | 61 +++++++++++++++++++ .../ReviewTransaction/styles.scss | 18 ++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx index 5deea9c815..28c879cd77 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx @@ -11,6 +11,21 @@ interface SwapRateRowProps { destinationAmount: string; } +// The rate value can be long ("1 yXLMUSD ≈ 0.00000012 yBTCETH") while the +// "Rate" label is short, so the row gives the label only the width it needs +// and lets the value fill the rest, stepping the value's font-size down by +// length so it isn't cropped. Mirrors the AVAILABLE_BALANCE_FONT_SIZES scale +// used on the swap amount screen. +const RATE_VALUE_FONT_SIZES = [ + { maxLen: 26, sizePx: 14 }, + { maxLen: 34, sizePx: 13 }, + { maxLen: 42, sizePx: 12 }, + { maxLen: Infinity, sizePx: 11 }, +] as const; + +export const getRateValueFontSizePx = (value: string): number => + RATE_VALUE_FONT_SIZES.find(({ maxLen }) => value.length <= maxLen)!.sizePx; + export const SwapRateRow = ({ srcCode, dstCode, @@ -19,8 +34,9 @@ export const SwapRateRow = ({ }: SwapRateRowProps) => { const { t } = useTranslation(); const rate = calculateSwapRate({ sendAmount, destinationAmount }); + const rateValue = `1 ${srcCode} ≈ ${rate} ${dstCode}`; return ( -
+
{t("Rate")} @@ -28,8 +44,9 @@ export const SwapRateRow = ({
- {`1 ${srcCode} ≈ ${rate} ${dstCode}`} + {rateValue}
); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx new file mode 100644 index 0000000000..774b3412ab --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { Wrapper } from "popup/__testHelpers__"; +import { + SwapRateRow, + getRateValueFontSizePx, +} from "popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow"; + +describe("getRateValueFontSizePx", () => { + it("keeps the base size for short rate values", () => { + expect(getRateValueFontSizePx("1 XLM ≈ 0.5 USDC")).toBe(14); + }); + + it("steps the font-size down as the value grows", () => { + expect(getRateValueFontSizePx("x".repeat(30))).toBe(13); + expect(getRateValueFontSizePx("x".repeat(40))).toBe(12); + expect(getRateValueFontSizePx("x".repeat(60))).toBe(11); + }); +}); + +describe("SwapRateRow", () => { + const renderRow = ( + props: Partial>, + ) => + render( + + + , + ); + + it("renders the rate value and the rate-row layout modifier", () => { + const { container } = renderRow({}); + const value = screen.getByTestId("review-tx-rate"); + expect(value).toHaveTextContent("1 XLM ≈ 0.5 USDC"); + expect( + container.querySelector(".ReviewTx__Details__Row--rate"), + ).toBeInTheDocument(); + }); + + it("derives the value font-size from the rendered rate length", () => { + renderRow({ + srcCode: "LONGTOKENA", + dstCode: "LONGTOKENB", + sendAmount: "3", + destinationAmount: "1", + }); + const value = screen.getByTestId("review-tx-rate"); + // The applied font-size matches the scale for whatever rate string renders, + // so a longer rate is automatically shrunk without cropping. + expect(value.style.fontSize).toBe( + `${getRateValueFontSizePx(value.textContent || "")}px`, + ); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss index 34b3e5ad94..8ea9879985 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss @@ -58,6 +58,24 @@ min-width: 0; } + // The rate row gives the short "Rate" label only its content width (plus + // a 20px gap) and lets the value take the rest, so a long rate isn't + // squeezed into a 50% column (§ task 5). The value's font-size steps down + // by length in the component. + &--rate { + .ReviewTx__Details__Row__Title { + flex: 0 0 auto; + margin-right: pxToRem(20px); + } + + .ReviewTx__Details__Row__Value { + flex: 1 1 auto; + min-width: 0; + align-items: center; + text-align: right; + } + } + &__Title { flex: 2; display: flex; From e3785a8ae25b012ada7d489f18b9d4e5d40ba2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Sun, 28 Jun 2026 18:52:00 -0300 Subject: [PATCH 098/121] Keep a new token's icon through the Swapping/Swapped! states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SubmitTransaction/index.tsx | 9 +++- .../__tests__/SubmitTransaction.test.tsx | 47 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx index d3d02d6b29..f0eacc2f14 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx @@ -140,7 +140,14 @@ export const SendingTransaction = ({ const isSuccess = submissionState.state === RequestState.SUCCESS; const assetIcon = icons[asset]!; const assetIcons = asset !== "native" ? { [asset]: assetIcon } : {}; - const dstAssetIcon = icons[destinationAsset]!; + // A new (not-yet-held) destination token has no entry in the icon cache, so + // fall back to the iconUrl carried on the picked token's details — the same + // source the picker and review screen use — so the icon persists through the + // Swapping/Swapped! states instead of showing a broken image (§ task 6). + const dstAssetIcon = + icons[destinationAsset] || + transactionData.destinationTokenDetails?.iconUrl || + ""; const dstAssetIcons = destinationAsset !== "native" ? { [destinationAsset]: dstAssetIcon } : {}; diff --git a/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx b/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx index 23cec5dc5a..f7c6a08b72 100644 --- a/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx +++ b/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx @@ -141,6 +141,53 @@ describe("SubmitTransaction", () => { }); }); + it("uses the picked token's iconUrl for a new destination token not yet in the icon cache", async () => { + const AQUA = + "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + const iconUrl = "https://example.com/aqua.png"; + render( + + {}} /> + , + ); + + await waitFor(() => { + expect(screen.getByAltText("AQUA logo")).toHaveAttribute("src", iconUrl); + }); + }); + it("asks for password if session has expired mid flow", async () => { // when we make a fresh request to load account, we don't have the private key jest From 830a96e401592b09ab3aedadf2f8b0f77f3a6e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 01:21:45 -0300 Subject: [PATCH 099/121] Fix the swap-to picker loader getting stuck after a search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SwapAsset/__tests__/SwapAsset.test.tsx | 12 ++++- .../popup/components/swap/SwapAsset/index.tsx | 50 ++++++++++++------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx index 601ce76dc4..a0d300b828 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { Wrapper } from "popup/__testHelpers__"; import { RequestState } from "constants/request"; @@ -148,7 +148,7 @@ describe("SwapAsset selectionType", () => { }); }); - it("destination: typing clears all results and shows the loader until the lookup settles", () => { + it("destination: typing clears all results and shows the loader until the lookup settles", async () => { render( { expect(screen.queryByTestId("swap-picker-sections")).toBeNull(); expect(document.querySelector(".SwapFrom__loader")).toBeInTheDocument(); + + // ...and the loader CLEARS once the debounced lookup settles. Regression + // guard: the previous lookupState-value effect missed the cache's + // SUCCESS→SUCCESS repaint, leaving the loader stuck forever. + await waitFor(() => { + expect(document.querySelector(".SwapFrom__loader")).toBeNull(); + }); + expect(screen.getByTestId("swap-picker-sections")).toBeInTheDocument(); }); it("source: typing does not show the loader (the filter is synchronous)", () => { diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index 5bc9dab163..b04b6d43e3 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -70,16 +70,16 @@ export const SwapAsset = ({ // tokens (and empty search sections) before the new results arrive. Track a // pending flag from the moment the user types until the lookup settles so we // show the loader instead — clearing every result at once (§ task 4). + // + // The flag is cleared off the lookup promise (in the formik onSubmit below), + // NOT off a lookupState-value effect: the idle in-memory cache can dispatch + // FETCH_DATA_SUCCESS while the state is already SUCCESS (a SUCCESS→SUCCESS + // repaint), which a [lookupState.state] effect never observes — that left the + // loader stuck forever when typing or clearing the box. A monotonic sequence + // guards against a superseded (debounced/aborted) call clearing a newer + // pending search. const [isSearchPending, setIsSearchPending] = React.useState(false); - - React.useEffect(() => { - if ( - lookupState.state === RequestState.SUCCESS || - lookupState.state === RequestState.ERROR - ) { - setIsSearchPending(false); - } - }, [lookupState.state]); + const searchSeqRef = React.useRef(0); const isLoading = isDestination ? lookupState.state === RequestState.IDLE || @@ -89,7 +89,7 @@ export const SwapAsset = ({ const formik = useFormik({ initialValues: { searchTerm: "" }, - onSubmit: (values) => { + onSubmit: async (values) => { if (isDestination) { const resolvedFrom = fromState.data; const balances = @@ -108,14 +108,24 @@ export const SwapAsset = ({ resolvedFrom?.type === AppDataType.RESOLVED ? resolvedFrom.tokenPrices : {}; - lookupFetchData({ - searchTerm: values.searchTerm, - balances, - publicKey, - networkDetails, - icons, - tokenPrices, - }); + // Capture the sequence at submit time; clear the loader only if no + // newer keystroke has arrived by the time this lookup settles (covers + // the SUCCESS→SUCCESS cache repaint and aborted/superseded calls). + const seq = searchSeqRef.current; + try { + await lookupFetchData({ + searchTerm: values.searchTerm, + balances, + publicKey, + networkDetails, + icons, + tokenPrices, + }); + } finally { + if (seq === searchSeqRef.current) { + setIsSearchPending(false); + } + } } else { filterBalances(values.searchTerm); } @@ -137,8 +147,10 @@ export const SwapAsset = ({ formik.setFieldValue("searchTerm", val); // The destination lookup is async (debounced + network); show the loader // until it settles so all results clear at once on each keystroke. The - // source filter is synchronous, so it doesn't need this. + // source filter is synchronous, so it doesn't need this. Bump the sequence + // so a still-in-flight earlier lookup can't clear this newer pending state. if (isDestination) { + searchSeqRef.current += 1; setIsSearchPending(true); } debouncedSubmit(); From c423a6edb91f726e864eb1aaef455cbb4bbe6da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 01:24:12 -0300 Subject: [PATCH 100/121] Show the generic empty state for an unmatched address on the swap-from picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/SwapPickerSections.test.tsx | 18 ++++++++++++++++++ .../SwapAsset/SwapPickerSections/index.tsx | 9 ++++++++- .../SwapAsset/SwapPickerSections/styles.scss | 5 +++++ .../popup/components/swap/SwapAsset/index.tsx | 2 ++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index c76f30ac53..d2111f1d03 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -51,6 +51,9 @@ const emptyResult = { const baseProps = { onClickAsset: jest.fn(), stellarExpertUrl: "https://stellar.expert/explorer/public", + // Default to the destination picker so the Soroban-empty-state cases below + // behave as before; the source picker is covered by a dedicated test. + isDestination: true, }; describe("SwapPickerSections", () => { @@ -155,6 +158,21 @@ describe("SwapPickerSections", () => { expect(screen.queryByTestId("swap-picker-empty")).toBeNull(); }); + it("source picker: a pasted contract id with no matches shows the generic empty state, not the Soroban one", () => { + render( + , + ); + + // The Soroban "not supported" copy only makes sense on the swap-TO picker. + expect(screen.queryByTestId("swap-picker-empty-soroban")).toBeNull(); + expect(screen.getByTestId("swap-picker-empty")).toBeInTheDocument(); + }); + it("soft fallback notice rendered when isFallback", () => { render( void; stellarExpertUrl: string; + /** True on the swap-TO (destination) picker. The Soroban "not supported" + * empty state only applies to the destination; the source picker shows the + * generic "no tokens match" empty state instead (you can only swap FROM a + * token you already hold). */ + isDestination: boolean; } export const SwapPickerSections = ({ @@ -73,6 +78,7 @@ export const SwapPickerSections = ({ hiddenAssets = [], onClickAsset, stellarExpertUrl, + isDestination, }: SwapPickerSectionsProps) => { const { t } = useTranslation(); const [verifiedSheetOpen, setVerifiedSheetOpen] = useState(false); @@ -170,7 +176,8 @@ export const SwapPickerSections = ({ )} {!hasResults ? ( - result.hadSorobanMatches || isContractId(searchTerm.trim()) ? ( + isDestination && + (result.hadSorobanMatches || isContractId(searchTerm.trim())) ? (
) : ( )}
From 351e7cdf19444e5ae1321a391ffcdf5e61df0e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 01:30:08 -0300 Subject: [PATCH 101/121] Enable the swap "Enter an amount" CTA and focus the sell input on tap 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 --- .../components/amount/AmountCard/index.tsx | 8 ++- .../__tests__/SwapAmount.ctaGate.test.tsx | 65 +++++++++++++++++++ .../helpers/__tests__/swapCtaState.test.ts | 28 +++++++- .../swap/SwapAmount/helpers/swapCtaState.ts | 11 +++- .../components/swap/SwapAmount/index.tsx | 14 ++++ 5 files changed, 122 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index e0ea1b78ea..56493aced6 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -31,6 +31,9 @@ export interface AmountCardProps { isAmountTooHigh: boolean; isReadOnly?: boolean; autoFocus?: boolean; + /** Optional handle to the amount input so a parent can focus it (e.g. the + * swap "Enter an amount" CTA focuses the sell card). */ + amountInputRef?: React.RefObject; cryptoDecimals: number; onAmountChange: (next: { amount: string; newCursor: number }) => void; onAmountUsdChange: (next: { amount: string; newCursor: number }) => void; @@ -56,6 +59,7 @@ export const AmountCard = ({ isAmountTooHigh, isReadOnly = false, autoFocus = true, + amountInputRef, cryptoDecimals, onAmountChange, onAmountUsdChange, @@ -68,7 +72,9 @@ export const AmountCard = ({ // Width owned internally (replaces InputWidthContext, per design §3.3). const cryptoSpanRef = useRef(null); const fiatSpanRef = useRef(null); - const inputRef = useRef(null); + const localInputRef = useRef(null); + // Use the caller's ref when provided so a parent can focus the input. + const inputRef = amountInputRef ?? localInputRef; const [inputWidthCrypto, setInputWidthCrypto] = useState(0); const [inputWidthFiat, setInputWidthFiat] = useState(0); diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx index f947390bd9..836147dacf 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx @@ -4,6 +4,7 @@ import { screen, fireEvent, waitFor, + within, act, } from "@testing-library/react"; import BigNumber from "bignumber.js"; @@ -117,6 +118,70 @@ describe("SwapAmount CTA gate", () => { ); }); + it("enables the 'Enter an amount' CTA and focuses the sell input on tap, without opening review", () => { + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(false); + const goToNext = jest.fn(); + render( + + + , + ); + + const btn = screen.getByTestId("swap-amount-btn-continue"); + expect(btn).toBeEnabled(); + expect(btn).toHaveTextContent("Enter an amount"); + + // The sell card auto-focuses on mount; blur it so we can prove the CTA tap + // re-focuses it rather than submitting. + (document.activeElement as HTMLElement | null)?.blur(); + const sellInput = within(screen.getByTestId("swap-sell-card")).getByTestId( + "send-amount-amount-input", + ); + expect(sellInput).not.toHaveFocus(); + + fireEvent.click(btn); + + expect(sellInput).toHaveFocus(); + expect(goToNext).not.toHaveBeenCalled(); + }); + it("disables the CTA with a fee warning when a non-XLM swap lacks XLM for fees", async () => { jest .spyOn(XlmReserve, "shouldShowXlmReservePreflight") diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts index 8705bc4754..66a533d205 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts @@ -3,6 +3,7 @@ import { getSwapCtaState, SwapCtaInputs } from "../swapCtaState"; const base: SwapCtaInputs = { hasSource: true, hasDestination: true, + availableBalanceIsZero: false, amountIsZero: false, isAmountTooHigh: false, insufficientXlmForFees: false, @@ -21,13 +22,36 @@ describe("getSwapCtaState", () => { }); }); - it("prompts to enter an amount when the amount is zero", () => { + it("prompts to enter an amount (enabled, so the tap focuses the sell input) when the amount is zero", () => { expect(getSwapCtaState({ ...base, amountIsZero: true })).toEqual({ - disabled: true, + disabled: false, labelKey: "enter", }); }); + it("flags insufficient balance (disabled) when the spendable balance is zero, even before an amount is entered", () => { + expect( + getSwapCtaState({ + ...base, + availableBalanceIsZero: true, + amountIsZero: true, + }), + ).toEqual({ + disabled: true, + labelKey: "insufficientBalance", + }); + }); + + it("prefers the zero-balance blocker over the enter state", () => { + expect( + getSwapCtaState({ + ...base, + availableBalanceIsZero: true, + amountIsZero: true, + }).labelKey, + ).toBe("insufficientBalance"); + }); + it("flags insufficient balance over the source spendable", () => { expect(getSwapCtaState({ ...base, isAmountTooHigh: true })).toEqual({ disabled: true, diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts index 74d105ba8b..b43617c924 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts @@ -13,6 +13,7 @@ export type SwapCtaLabelKey = export interface SwapCtaInputs { hasSource: boolean; hasDestination: boolean; + availableBalanceIsZero: boolean; amountIsZero: boolean; isAmountTooHigh: boolean; insufficientXlmForFees: boolean; @@ -22,6 +23,7 @@ export interface SwapCtaInputs { export const getSwapCtaState = ({ hasSource, hasDestination, + availableBalanceIsZero, amountIsZero, isAmountTooHigh, insufficientXlmForFees, @@ -33,8 +35,15 @@ export const getSwapCtaState = ({ if (!hasSource || !hasDestination) { return { disabled: false, labelKey: "select" }; } + // Nothing the user can enter will be valid with zero spendable balance, so + // surface the balance blocker directly (disabled) before the enter state. + if (availableBalanceIsZero) { + return { disabled: true, labelKey: "insufficientBalance" }; + } + // Both tokens picked but no amount yet: ENABLED so tapping it focuses the + // sell input (mirrors mobile useSwapCtaState — "enter" is not disabled). if (amountIsZero) { - return { disabled: true, labelKey: "enter" }; + return { disabled: false, labelKey: "enter" }; } if (isAmountTooHigh) { return { disabled: true, labelKey: "insufficientBalance" }; diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index f1d8a4a00e..1ea7af6bf7 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -322,6 +322,8 @@ export const SwapAmount = ({ // the full simulation runs at review time in handleContinue. A monotonic // request id discards out-of-order responses; failures reset the displayed // amount to 0 so a stale quote never lingers. + // Lets the "Enter an amount" CTA focus the sell input on tap (§ task 1). + const sellInputRef = useRef(null); const liveQuoteReqRef = useRef(0); const liveQuoteArgsRef = useRef({ asset, destinationAsset, networkDetails }); liveQuoteArgsRef.current = { asset, destinationAsset, networkDetails }; @@ -671,6 +673,11 @@ export const SwapAmount = ({ const cta = getSwapCtaState({ hasSource: !!asset, hasDestination: !!destinationAsset, + // availableBalance already nets out the network fee + the new-trustline + // 0.5 XLM reserve, so a barely-funded account correctly reads as empty. + availableBalanceIsZero: new BigNumber( + cleanAmount(availableBalance), + ).isLessThanOrEqualTo(0), amountIsZero: !swapAmountPositive, isAmountTooHigh, insufficientXlmForFees, @@ -759,6 +766,12 @@ export const SwapAmount = ({ } return; } + // Both tokens picked but no amount yet: focus the sell input so + // the user knows to type an amount (mirrors mobile; § task 1). + if (cta.labelKey === "enter") { + sellInputRef.current?.focus(); + return; + } formik.submitForm(); }} > @@ -806,6 +819,7 @@ export const SwapAmount = ({ // to XLM but the receive side is empty, so the card stays // unfocused with a gray "0" placeholder (§ task 1). autoFocus={!!asset && !!destinationAsset} + amountInputRef={sellInputRef} assetCode={srcAsset ? srcAsset.code : ""} assetIcon={assetIcon} assetIcons={ From b894df834b9dea79a24de413f23df071a8b99c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 01:34:50 -0300 Subject: [PATCH 102/121] Fix the swap fiat-toggle first-tap glitches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AmountCard/__tests__/index.test.tsx | 21 ++++ .../components/amount/AmountCard/index.tsx | 110 +++++++++--------- .../components/swap/SwapAmount/index.tsx | 23 +++- 3 files changed, 94 insertions(+), 60 deletions(-) diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index fc50505cc0..6d57b9681e 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -121,6 +121,27 @@ describe("AmountCard", () => { expect(onSelectAsset).toHaveBeenCalledTimes(1); }); + it("does not select-all the fiat amount on focus (matches the crypto input)", () => { + render( + + + , + ); + const input = screen.getByTestId( + "send-amount-amount-input", + ) as HTMLInputElement; + fireEvent.focus(input); + // The previous fiat-only onFocus={e => e.target.select()} highlighted the + // whole amount on the first toggle; the selection must stay collapsed. + expect(input.selectionStart).toBe(input.selectionEnd); + }); + it("fires onSelectAsset when asset selector is clicked even with isReadOnly", () => { const onSelectAsset = jest.fn(); render( diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 56493aced6..cb3e4cb112 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -116,49 +116,63 @@ export const AmountCard = ({ onClick={isReadOnly ? undefined : () => inputRef.current?.focus()} style={isReadOnly ? undefined : { cursor: "text" }} > + {/* Hidden mirrors used to size each input to its content. BOTH are + always rendered so the inactive input's width is measured before + the first crypto<->fiat toggle — otherwise the toggled-in input + briefly falls back to DEFAULT_INPUT_WIDTH and the value is clipped + for a frame (§ task 8). */} + + {amount || "0"} + + + {amountUsd || "0"} + {inputType === "crypto" && ( - <> - - {amount || "0"} - - { - const input = e.target; - const next = formatAmountPreserveCursor( - e.target.value, - amount, - cryptoDecimals, - e.target.selectionStart || 1, - ); - onAmountChange(next); - runAfterUpdate(() => { - input.selectionStart = next.newCursor; - input.selectionEnd = next.newCursor; - }); - }} - autoFocus={autoFocus} - autoComplete="off" - /> - + { + const input = e.target; + const next = formatAmountPreserveCursor( + e.target.value, + amount, + cryptoDecimals, + e.target.selectionStart || 1, + ); + onAmountChange(next); + runAfterUpdate(() => { + input.selectionStart = next.newCursor; + input.selectionEnd = next.newCursor; + }); + }} + autoFocus={autoFocus} + autoComplete="off" + /> )} {inputType === "fiat" && ( <> @@ -167,17 +181,6 @@ export const AmountCard = ({ > $
- - {amountUsd || "0"} - e.target.select()} /> )} diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 1ea7af6bf7..f6a7b40687 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -246,10 +246,13 @@ export const SwapAmount = ({ validateOnChange: true, }); - const getAmountFontSizeClass = (): "lg" | "med" | "small" | "xsmall" => { - const currentValue = - inputType === "fiat" ? formik.values.amountUsd : formik.values.amount; - const digitsLength = currentValue.replace(/[^0-9]/g, "").length; + // Size the displayed amount by its digit count. Each card passes its OWN + // value so the read-only receive amount isn't sized off the sell amount + // (which mis-sized and clipped it on toggle; § task 8). + const getAmountFontSizeClass = ( + value: string, + ): "lg" | "med" | "small" | "xsmall" => { + const digitsLength = (value || "").replace(/[^0-9]/g, "").length; if (digitsLength <= 6) { return "lg"; } @@ -813,7 +816,11 @@ export const SwapAmount = ({ ? "" : formik.values.amountUsd } - amountFontSizeClass={getAmountFontSizeClass()} + amountFontSizeClass={getAmountFontSizeClass( + inputType === "fiat" + ? formik.values.amountUsd + : formik.values.amount, + )} // Don't grab focus until the swap is ready to receive an // amount (both tokens picked); on entry the source defaults // to XLM but the receive side is empty, so the card stays @@ -921,7 +928,11 @@ export const SwapAmount = ({ inputType={inputType} amount={destinationAmount} amountUsd={dstPriceValueUsd || "0.00"} - amountFontSizeClass={getAmountFontSizeClass()} + amountFontSizeClass={getAmountFontSizeClass( + inputType === "fiat" + ? dstPriceValueUsd || "0.00" + : destinationAmount, + )} assetCode={dstAsset ? dstAsset.code : ""} assetIcon={dstAssetIcon} assetIcons={ From 72d858382521554596f5b951541f61ba00924a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 01:41:34 -0300 Subject: [PATCH 103/121] Show a single Blockaid banner on the swap review + badge the flagged icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/ReviewTx.security.test.tsx | 75 +++++++++++++------ .../components/SendAsset.tsx | 5 +- .../components/SendDestination.tsx | 5 +- .../ReviewTransaction/index.tsx | 61 ++++++++------- 4 files changed, 98 insertions(+), 48 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx index 6972013aff..1d6d2f33ac 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx @@ -37,14 +37,27 @@ const swapProps = { const renderReview = ({ destLevel, sourceLevel, + scanResult, }: { destLevel?: SecurityLevel; sourceLevel?: SecurityLevel; + scanResult?: unknown; }) => render( renderReview({ destLevel }); -describe("ReviewTx destination-token security gate", () => { - it("shows the destination-token warning and the Confirm-anyway gate when the token is malicious", () => { +describe("ReviewTx Blockaid security banner (single, by priority) + badges", () => { + it("shows one malicious token banner, the Confirm-anyway gate, and the icon badge for a malicious destination", () => { renderWithDestLevel(SecurityLevel.MALICIOUS); - expect( - screen.getByTestId("review-tx-dest-token-warning"), - ).toBeInTheDocument(); + const banner = screen.getByTestId("review-tx-token-warning"); + expect(banner).toHaveTextContent( + "The token you're receiving was flagged as malicious by Blockaid.", + ); // Case-3 "Confirm anyway" gate renders the dedicated CancelAction button. expect(screen.getByTestId("CancelAction")).toBeInTheDocument(); + // The warning badge overlays the (destination) token icon. + expect(screen.getAllByTestId("ScamAssetIcon").length).toBe(1); }); - it("shows the destination-token warning when the token is suspicious", () => { + it("shows one suspicious token banner for a suspicious destination", () => { renderWithDestLevel(SecurityLevel.SUSPICIOUS); - expect( - screen.getByTestId("review-tx-dest-token-warning"), - ).toBeInTheDocument(); + expect(screen.getByTestId("review-tx-token-warning")).toHaveTextContent( + "The token you're receiving was flagged as suspicious by Blockaid.", + ); }); - it("does not show a destination-token warning when the token is safe", () => { + it("does not show a token warning or a badge when the token is safe", () => { renderWithDestLevel(SecurityLevel.SAFE); expect( - screen.queryByTestId("review-tx-dest-token-warning"), + screen.queryByTestId("review-tx-token-warning"), ).not.toBeInTheDocument(); + expect(screen.queryByTestId("ScamAssetIcon")).not.toBeInTheDocument(); }); - it("shows the source-token warning + Confirm-anyway gate when the sell token is malicious", () => { + it("shows one source-token banner + Confirm-anyway gate + badge when the sell token is malicious", () => { renderReview({ sourceLevel: SecurityLevel.MALICIOUS }); - expect( - screen.getByTestId("review-tx-source-token-warning"), - ).toBeInTheDocument(); + expect(screen.getByTestId("review-tx-token-warning")).toHaveTextContent( + "The token you're sending was flagged as malicious by Blockaid.", + ); expect(screen.getByTestId("CancelAction")).toBeInTheDocument(); + expect(screen.getAllByTestId("ScamAssetIcon").length).toBe(1); }); - it("warns for both sides independently", () => { + it("collapses both flagged sides into a single banner (worst level wins) but badges both icons", () => { renderReview({ sourceLevel: SecurityLevel.SUSPICIOUS, destLevel: SecurityLevel.MALICIOUS, }); + // Exactly one banner, reflecting the worst level (malicious destination). + const banners = screen.getAllByTestId("review-tx-token-warning"); + expect(banners).toHaveLength(1); + expect(banners[0]).toHaveTextContent( + "The token you're receiving was flagged as malicious by Blockaid.", + ); + // ...but the per-icon badge still appears on both flagged tokens. + expect(screen.getAllByTestId("ScamAssetIcon").length).toBe(2); + }); + + it("prefers the transaction-scan banner over the token banner (tx outranks token)", () => { + renderReview({ + destLevel: SecurityLevel.MALICIOUS, + scanResult: { validation: { result_type: "Malicious" } }, + }); + // The transaction banner (which opens the expandable pane) is shown... + expect(screen.getByTestId("blockaid-malicious-label")).toBeInTheDocument(); + // ...and the token banner is suppressed so only one Blockaid banner shows. expect( - screen.getByTestId("review-tx-source-token-warning"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("review-tx-dest-token-warning"), - ).toBeInTheDocument(); + screen.queryByTestId("review-tx-token-warning"), + ).not.toBeInTheDocument(); }); }); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SendAsset.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SendAsset.tsx index 29550320a3..5239531b6d 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SendAsset.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SendAsset.tsx @@ -20,6 +20,8 @@ interface SendAssetProps { sendAmount: string; networkDetails: NetworkDetails; sendPriceUsd: string | null; + /** Show the Blockaid warning badge over the icon for a flagged source token. */ + isSuspicious?: boolean; } export const SendAsset: React.FC = ({ @@ -31,6 +33,7 @@ export const SendAsset: React.FC = ({ sendAmount, networkDetails, sendPriceUsd, + isSuspicious = false, }) => { if (isCollectible) { return ( @@ -72,7 +75,7 @@ export const SendAsset: React.FC = ({ code={asset.code} issuerKey={asset.issuer} icon={assetIcon} - isSuspicious={false} + isSuspicious={isSuspicious} />
= ({ @@ -24,6 +26,7 @@ export const SendDestination: React.FC = ({ networkDetails, destination, truncatedDest, + isSuspicious = false, }) => { if (dstAsset && dest) { return ( @@ -37,7 +40,7 @@ export const SendDestination: React.FC = ({ code={dest.code} issuerKey={dest.issuer} icon={dstAsset.icon} - isSuspicious={false} + isSuspicious={isSuspicious} />
diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index d3ac89f06c..6ed5a0f644 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -238,6 +238,24 @@ export const ReviewTx = ({ ) : null; + // We show at most ONE Blockaid banner, by priority (mirrors mobile's + // useReviewSecuritySummary): the transaction verdict outranks the token + // verdict, and among tokens the worse level wins (the destination breaks a + // tie, since it's the token being acquired). When the transaction scan itself + // is flagged its banner renders below; otherwise this single token banner + // does. + const tokenWarningLevel = mergeSecurityLevels([ + sourceTokenSecurityLevel ?? null, + destTokenSecurityLevel, + ]); + const tokenWarningMessage = + destTokenSecurityLevel && destTokenSecurityLevel === tokenWarningLevel + ? destTokenWarningMessage + : sourceTokenSecurityLevel && + sourceTokenSecurityLevel === tokenWarningLevel + ? sourceTokenWarningMessage + : null; + /** * Pane state machine: * - No warning: [Review (0), Memo (1), Fees (2)] @@ -375,6 +393,10 @@ export const ReviewTx = ({ sendAmount={sendAmount} networkDetails={networkDetails} sendPriceUsd={sendPriceUsd} + isSuspicious={ + sourceTokenSecurityLevel === SecurityLevel.MALICIOUS || + sourceTokenSecurityLevel === SecurityLevel.SUSPICIOUS + } />
@@ -390,15 +412,20 @@ export const ReviewTx = ({ networkDetails={networkDetails} destination={destination} truncatedDest={truncatedDest} + isSuspicious={ + destTokenSecurityLevel === SecurityLevel.MALICIOUS || + destTokenSecurityLevel === SecurityLevel.SUSPICIOUS + } />
- {/* Transaction-scan banner (opens the expandable Blockaid pane). Gated - on the tx verdict so a token-only warning doesn't open an empty - pane — the token verdict gets its own banner below. */} - {txSecurityLevel && paneConfig.blockaidIndex !== null && ( + {/* Exactly one Blockaid banner, by priority. The transaction-scan + banner (which opens the expandable Blockaid pane) outranks the token + verdict; when the transaction itself isn't flagged we fall back to a + single consolidated token banner. */} + {txSecurityLevel && paneConfig.blockaidIndex !== null ? ( { @@ -407,37 +434,21 @@ export const ReviewTx = ({ } }} /> - )} - {sourceTokenWarningMessage && ( + ) : tokenWarningMessage ? (
- )} - {destTokenWarningMessage && ( -
- -
- )} + ) : null} {isRequiredMemoMissing && !isValidatingMemo && !shouldShowTxWarning && ( setActivePaneIndex(paneConfig.memoIndex)} From 17e96850c2124f36eb5a8b765cfa65b0409f5a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 01:43:31 -0300 Subject: [PATCH 104/121] Hide the swap review body while the trustline info sheet is open 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 --- .../__tests__/ReviewTx.trustlineBanner.test.tsx | 16 ++++++++++++++++ .../ReviewTransaction/index.tsx | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx index 5f3ab80d39..878af6b8d1 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx @@ -52,12 +52,28 @@ describe("ReviewTx trustline banner", () => { /> , ); + // The review body is visible before the sheet opens. + expect( + screen.getByTestId("review-tx-send-destination"), + ).toBeInTheDocument(); + const banner = screen.getByTestId("review-tx-trustline-banner"); // i18n interpolation is not processed in the test environment; // confirm the banner element is present (tokenCode wired) and clickable expect(banner).toBeInTheDocument(); fireEvent.click(banner); + + // The trustline sheet opens and the review body is hidden behind it (so it + // doesn't show as a ghost), then is restored when the sheet is closed. expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); + expect( + screen.queryByTestId("review-tx-send-destination"), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("trustline-info-sheet-close")); + expect( + screen.getByTestId("review-tx-send-destination"), + ).toBeInTheDocument(); }); it("does not render the banner when no trustline is required", () => { diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 6ed5a0f644..26cb6a8f46 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -592,7 +592,13 @@ export const ReviewTx = ({ /> ) : (
- + {/* Hide the review body while the trustline info sheet is up so the + review sheet doesn't show as a ghost behind it; closing the sheet + restores it (§ task 5). Matches the existing ActionButtons guard + below. */} + {!isOnTrustlinePane && ( + + )} Date: Mon, 29 Jun 2026 01:48:12 -0300 Subject: [PATCH 105/121] Surface the swap quote-expired notice as a transient toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/SwapAmount.telemetry.test.tsx | 18 +++++--- .../components/swap/SwapAmount/index.tsx | 46 +++++++++++-------- .../components/swap/SwapAmount/styles.scss | 4 -- 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx index 795dc99367..72cdfa4905 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx @@ -14,6 +14,7 @@ import { Wrapper } from "popup/__testHelpers__"; import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; import { SwapAmount } from "popup/components/swap/SwapAmount"; import { emitMetric } from "helpers/metrics"; +import { toast } from "sonner"; import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; import * as UseNetworkFees from "popup/helpers/useNetworkFees"; @@ -24,7 +25,12 @@ jest.mock("helpers/metrics", () => ({ emitMetric: jest.fn(), })); +// The quote-expired notice is a sonner toast; assert it fires rather than +// rendering the portal (the test Wrapper doesn't mount a Toaster). +jest.mock("sonner", () => ({ toast: { custom: jest.fn() } })); + const emitMetricMock = emitMetric as jest.Mock; +const toastCustomMock = toast.custom as jest.Mock; const nativeBalance = { token: { type: "native", code: "XLM" }, @@ -104,6 +110,7 @@ describe("SwapAmount telemetry + quote-expired surfacing", () => { afterEach(() => { jest.restoreAllMocks(); emitMetricMock.mockClear(); + toastCustomMock.mockClear(); }); it("shows the quote-expired notice and emits swapQuoteExpired when flagged", async () => { @@ -115,14 +122,11 @@ describe("SwapAmount telemetry + quote-expired surfacing", () => { renderSwapAmount({}); + // The quote-expired toast fires (sonner toast.custom) instead of a fixed + // banner taking layout space. await waitFor(() => { - expect(screen.getByTestId("swap-quote-expired")).toBeInTheDocument(); + expect(toastCustomMock).toHaveBeenCalled(); }); - expect( - screen.getByText( - "Quote has expired, please try again to get a new quote", - ), - ).toBeInTheDocument(); const expiredCall = emitMetricMock.mock.calls.find( (c) => c[0] === "swap: quote expired", @@ -156,7 +160,7 @@ describe("SwapAmount telemetry + quote-expired surfacing", () => { screen.getByTestId("swap-amount-btn-continue"), ).toBeInTheDocument(); }); - expect(screen.queryByTestId("swap-quote-expired")).toBeNull(); + expect(toastCustomMock).not.toHaveBeenCalled(); expect( emitMetricMock.mock.calls.find((c) => c[0] === "swap: quote expired"), ).toBeUndefined(); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index f6a7b40687..5a7fdf453c 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -4,6 +4,7 @@ import { Navigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Form, Field, FieldProps, Formik, useFormik } from "formik"; import { debounce } from "lodash"; +import { toast } from "sonner"; import BigNumber from "bignumber.js"; import { captureException } from "@sentry/browser"; import { object as YupObject, number as YupNumber } from "yup"; @@ -189,7 +190,6 @@ export const SwapAmount = ({ const [isEditingSettings, setIsEditingSettings] = useState(false); const [isReviewingTx, setIsReviewingTx] = React.useState(false); const [isXlmReserveOpen, setIsXlmReserveOpen] = useState(false); - const [showQuoteExpired, setShowQuoteExpired] = useState(false); // True while a live best-path quote is in flight, so the CTA can tell // "still loading a quote" apart from "no path exists" (§2.5). const [isLiveQuoteLoading, setIsLiveQuoteLoading] = useState(false); @@ -299,16 +299,30 @@ export const SwapAmount = ({ setInputType, ]); + // A transient, swipe-/auto-dismissible toast (sonner) rather than a fixed + // banner that takes layout space. The stable id dedupes the in-screen + // (isQuoteExpired) and submit-recovery (isSwapQuoteExpired) triggers into one + // toast instead of stacking two. + const showQuoteExpiredToast = () => + toast.custom( + () => ( + + ), + { id: "swap-quote-expired" }, + ); + // Quote-expired surfacing: when the simulate hook flags an expired quote // (Horizon op_under_dest_min / op_too_few_offers), emit the metric and show - // the user-facing notification. The auto-refetch is handled by Phase E's - // getBestPath retry; this only emits + surfaces the message. + // the user-facing toast. The auto-refetch is handled by Phase E's getBestPath + // retry; this only emits + surfaces the message. useEffect(() => { if (!isQuoteExpired) { - setShowQuoteExpired(false); return; } - setShowQuoteExpired(true); + showQuoteExpiredToast(); emitMetric(METRIC_NAMES.swapQuoteExpired, { sourceToken: asset, destToken: destinationAsset, @@ -319,6 +333,15 @@ export const SwapAmount = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isQuoteExpired]); + // A quote that expired at submit time (Redux flag) routes back to this screen; + // surface the same toast on arrival. + useEffect(() => { + if (isSwapQuoteExpired) { + showQuoteExpiredToast(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSwapQuoteExpired]); + // Live quote: debounce the source amount and fetch the best path so the // "You receive" amount updates as the user types. This is a lightweight // path-only lookup (no XDR build / Blockaid scan / quote-expiry surfacing) — @@ -784,19 +807,6 @@ export const SwapAmount = ({ } >
- {(showQuoteExpired || isSwapQuoteExpired) && ( -
- -
- )}
diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index 53782e7399..b2a69a272c 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -9,10 +9,6 @@ padding: pxToRem(16px); } - &__quote-expired { - padding: pxToRem(8px) pxToRem(16px) 0; - } - &__subtitle { text-align: center; } From 93bdf77aa0a9600dabcb046dfd75a838ef458c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 01:56:45 -0300 Subject: [PATCH 106/121] Rebuild the XLM-reserve sheet to match Figma + fix the help link 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 --- .../components/swap/SwapAmount/index.tsx | 7 +- .../__tests__/XlmReserveSheet.test.tsx | 28 ++++ .../components/swap/XlmReserveSheet/index.tsx | 83 ++++++++---- .../swap/XlmReserveSheet/styles.scss | 121 +++++++++++++++++- .../__tests__/translationParity.test.ts | 7 + .../src/popup/locales/en/translation.json | 3 + .../src/popup/locales/pt/translation.json | 3 + 7 files changed, 224 insertions(+), 28 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 5a7fdf453c..d9b584dac6 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -101,6 +101,10 @@ const AVAILABLE_BALANCE_FONT_SIZES = [ { maxLen: Infinity, sizePx: 11 }, ] as const; +// "Why do I need XLM?" help article (matches freighter-mobile). +const XLM_RESERVE_HELP_URL = + "https://help.freighter.app/article/xjlva9dxov-how-much-xlm-do-i-need-in-my-wallet"; + interface SwapAmountProps { inputType: InputType; setInputType: (type: InputType) => void; @@ -1105,7 +1109,8 @@ export const SwapAmount = ({ onClose={() => setIsXlmReserveOpen(false)} publicKey={publicKey} canSwapForReserve={canSwapForReserve} - helpUrl="" + helpUrl={XLM_RESERVE_HELP_URL} + tokenCode={dstAsset ? dstAsset.code : ""} onSwapForReserve={handleSwapForReserve} /> ) : ( diff --git a/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx b/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx index edb87c4519..fc9248463e 100644 --- a/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx +++ b/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx @@ -56,4 +56,32 @@ describe("XlmReserveSheet", () => { fireEvent.click(screen.getByTestId("XlmReserveSheet__why-xlm")); expect(openTab).toHaveBeenCalledWith(HELP_URL); }); + + it("dismisses via the close button", () => { + const onClose = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("XlmReserveSheet__close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders the reserve card and the XLM icon", () => { + render( + , + ); + expect(screen.getByText("0.5 XLM required")).toBeInTheDocument(); + expect(screen.getByAltText("XLM")).toBeInTheDocument(); + }); }); diff --git a/extension/src/popup/components/swap/XlmReserveSheet/index.tsx b/extension/src/popup/components/swap/XlmReserveSheet/index.tsx index 3521c743e9..6efd95ab3b 100644 --- a/extension/src/popup/components/swap/XlmReserveSheet/index.tsx +++ b/extension/src/popup/components/swap/XlmReserveSheet/index.tsx @@ -1,8 +1,9 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Button, CopyText, Icon, Text } from "@stellar/design-system"; +import { Button, CopyText, Icon } from "@stellar/design-system"; import { openTab } from "popup/helpers/navigate"; +import StellarLogo from "popup/assets/stellar-logo.png"; import "./styles.scss"; @@ -11,6 +12,8 @@ interface XlmReserveSheetProps { onSwapForReserve?: () => void; publicKey: string; helpUrl: string; + /** Destination token code, interpolated into the body + reserve copy. */ + tokenCode?: string; onClose: () => void; } @@ -19,29 +22,76 @@ export const XlmReserveSheet = ({ onSwapForReserve, publicKey, helpUrl, + tokenCode = "", onClose, }: XlmReserveSheetProps) => { const { t } = useTranslation(); return (
-
- +
+
+ +
+ +
+ +
+

{t("You need XLM to create a trustline")} - - +

+

{t( - "Adding a trustline locks a one-time 0.5 XLM reserve in your account. You can recover it later by removing the trustline.", - )} - + "To receive {{tokenCode}}, your wallet needs a trustline on Stellar.", + { + tokenCode, + }, + )}{" "} + +

+
+ +
+ XLM +
+ + {t("0.5 XLM required")} + + + {t( + "Stellar requires this reserve to add {{tokenCode}}. You can get it back once your {{tokenCode}} balance is zero.", + { tokenCode }, + )} + +
{canSwapForReserve ? ( - -
); diff --git a/extension/src/popup/components/swap/XlmReserveSheet/styles.scss b/extension/src/popup/components/swap/XlmReserveSheet/styles.scss index 18f345d23e..8285ce9e2b 100644 --- a/extension/src/popup/components/swap/XlmReserveSheet/styles.scss +++ b/extension/src/popup/components/swap/XlmReserveSheet/styles.scss @@ -1,18 +1,129 @@ +@use "../../../styles/utils.scss" as *; + .XlmReserveSheet { display: flex; flex-direction: column; - gap: 1.5rem; - padding: 1.5rem; + gap: pxToRem(24px); + padding: pxToRem(24px); + + &__top { + display: flex; + align-items: center; + justify-content: space-between; + } + + // Brand (lilac) icon badge — matches the shared InfoSheet badge. + &__badge { + display: flex; + align-items: center; + justify-content: center; + width: pxToRem(32px); + height: pxToRem(32px); + border-radius: pxToRem(8px); + border: 1px solid var(--sds-clr-lilac-06); + background-color: var(--sds-clr-lilac-03); + color: var(--sds-clr-lilac-11); + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + } + } - &__header { + &__close { + display: flex; + align-items: center; + justify-content: center; + width: pxToRem(32px); + height: pxToRem(32px); + padding: 0; + border: 0; + border-radius: 50%; + background-color: var(--sds-clr-gray-03); + color: var(--sds-clr-gray-11); + cursor: pointer; + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + } + } + + &__text { display: flex; flex-direction: column; - gap: 0.5rem; + gap: pxToRem(8px); + } + + &__title { + margin: 0; + color: var(--sds-clr-gray-12); + font-size: pxToRem(18px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(26px); + } + + &__body { + margin: 0; + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-regular); + line-height: pxToRem(20px); + } + + // Inline "Why do I need XLM?" link, flowing within the body paragraph. + &__why-link { + display: inline; + padding: 0; + border: 0; + background: none; + color: var(--sds-clr-lilac-11); + font: inherit; + font-weight: var(--sds-fw-medium); + cursor: pointer; + } + + // Reserve info card: token icon + "0.5 XLM required" + explanation. + &__card { + display: flex; + align-items: center; + gap: pxToRem(12px); + padding: pxToRem(16px); + border-radius: pxToRem(16px); + background-color: var(--sds-clr-gray-03); + } + + &__card-icon { + flex-shrink: 0; + width: pxToRem(32px); + height: pxToRem(32px); + border-radius: 50%; + object-fit: cover; + } + + &__card-text { + display: flex; + flex-direction: column; + gap: pxToRem(2px); + } + + &__card-title { + color: var(--sds-clr-gray-12); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(20px); + } + + &__card-body { + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(18px); } &__actions { display: flex; flex-direction: column; - gap: 0.75rem; + gap: pxToRem(12px); } } diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index cecd0cb2b0..1b47c7369a 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -18,6 +18,13 @@ const swapKeys = [ "The token you're sending was flagged as malicious by Blockaid.", "The token you're sending was flagged as suspicious by Blockaid.", "The token you're sending couldn't be scanned for security risks.", + "You need XLM to create a trustline", + "To receive {{tokenCode}}, your wallet needs a trustline on Stellar.", + "Why do I need XLM?", + "0.5 XLM required", + "Stellar requires this reserve to add {{tokenCode}}. You can get it back once your {{tokenCode}} balance is zero.", + "Swap for 0.5 XLM", + "Copy my wallet address", ]; describe("swap i18n parity", () => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 2ff033c3ae..c7e5ffda81 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -2,6 +2,7 @@ "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", "* payment methods may vary based on your location": "* payment methods may vary based on your location", + "0.5 XLM required": "0.5 XLM required", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "A new signing request arrived while you were reviewing another. Please review it carefully before approving.", "About": "About", @@ -578,6 +579,7 @@ "Stellar Development Foundation will never ask for your phrase": "Stellar Development Foundation will never ask for your phrase", "Stellar Logo": "Stellar Logo", "Stellar Network": "Stellar Network", + "Stellar requires this reserve to add {{tokenCode}}. You can get it back once your {{tokenCode}} balance is zero.": "Stellar requires this reserve to add {{tokenCode}}. You can get it back once your {{tokenCode}} balance is zero.", "Stellar token logo": "Stellar token logo", "Storing your secret key is your responsibility.": "Storing your secret key is your responsibility.", "Sub Invocation": "Sub Invocation", @@ -671,6 +673,7 @@ "To create a new account you need to send at least 1 XLM to it.": "To create a new account you need to send at least 1 XLM to it.", "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.", + "To receive {{tokenCode}}, your wallet needs a trustline on Stellar.": "To receive {{tokenCode}}, your wallet needs a trustline on Stellar.", "To start using this account, fund it with at least 1 XLM.": "To start using this account, fund it with at least 1 XLM.", "Toggle Assets": "Toggle Assets", "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 1b6032cf2b..c8e72dbd55 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -2,6 +2,7 @@ "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", "* payment methods may vary based on your location": "* Os métodos de pagamento podem variar com base na sua localização", + "0.5 XLM required": "0,5 XLM necessários", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "Uma conta de destino requer o uso do campo memo que não está presente na transação que você está prestes a assinar.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "Uma nova solicitação de assinatura chegou enquanto você revisava outra. Revise-a com atenção antes de aprovar.", "About": "Sobre", @@ -578,6 +579,7 @@ "Stellar Development Foundation will never ask for your phrase": "A Stellar Development Foundation nunca pedirá sua frase", "Stellar Logo": "Logo Stellar", "Stellar Network": "Rede Stellar", + "Stellar requires this reserve to add {{tokenCode}}. You can get it back once your {{tokenCode}} balance is zero.": "A Stellar exige essa reserva para adicionar {{tokenCode}}. Você pode recuperá-la quando o saldo de {{tokenCode}} ficar zerado.", "Stellar token logo": "Logotipo do token Stellar", "Storing your secret key is your responsibility.": "Armazenar sua chave secreta é sua responsabilidade.", "Sub Invocation": "Sub Invocação", @@ -671,6 +673,7 @@ "To create a new account you need to send at least 1 XLM to it.": "Para criar uma nova conta, você precisa enviar pelo menos 1 XLM para ela.", "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "Para manter {{code}} na sua carteira, a Stellar exige uma linha de confiança. 0,5 XLM serão reservados do seu saldo. Você pode recuperá-los removendo a linha de confiança após o saldo de {{code}} ficar zerado.", "To hold a new asset, your account locks a one-time 0.5 XLM reserve for its trustline.": "Para manter um novo ativo, sua conta reserva uma única vez 0,5 XLM para a linha de confiança.", + "To receive {{tokenCode}}, your wallet needs a trustline on Stellar.": "Para receber {{tokenCode}}, sua carteira precisa de uma linha de confiança na Stellar.", "To start using this account, fund it with at least 1 XLM.": "Para começar a usar esta conta, financie-a com pelo menos 1 XLM.", "Toggle Assets": "Alternar Ativos", "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.": "A descoberta de tokens está temporariamente indisponível. Você ainda pode trocar entre tokens que já possui.", From fd13f8e31b84de8d744114afff2ec18fd9ae99bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 07:06:25 -0300 Subject: [PATCH 107/121] Don't let an unscannable tx hide a malicious-token banner on review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/ReviewTx.security.test.tsx | 28 +++++++++++++ .../ReviewTransaction/index.tsx | 41 +++++++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx index 1d6d2f33ac..30aa497e7f 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx @@ -34,19 +34,31 @@ const swapProps = { }, }; +// PUBLIC network so an absent tx scan is treated as UNABLE_TO_SCAN (Blockaid +// is only enabled on mainnet). +const MAINNET = { + network: "PUBLIC", + networkName: "Main Net", + networkPassphrase: "Public Global Stellar Network ; September 2015", + networkUrl: "https://horizon.stellar.org", +} as any; + const renderReview = ({ destLevel, sourceLevel, scanResult, + networkDetails, }: { destLevel?: SecurityLevel; sourceLevel?: SecurityLevel; scanResult?: unknown; + networkDetails?: typeof swapProps.networkDetails; }) => render( { + // Mainnet + absent scan => tx verdict is UNABLE_TO_SCAN; a malicious token + // must not be downgraded to the soft "proceed with caution" tx banner. + renderReview({ + networkDetails: MAINNET, + scanResult: null, + destLevel: SecurityLevel.MALICIOUS, + }); + expect(screen.getByTestId("review-tx-token-warning")).toHaveTextContent( + "The token you're receiving was flagged as malicious by Blockaid.", + ); + expect( + screen.queryByTestId("blockaid-unable-to-scan-label"), + ).not.toBeInTheDocument(); + }); }); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 26cb6a8f46..4740217954 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -279,6 +279,34 @@ export const ReviewTx = ({ [shouldShowTxWarning], ); + // Which single Blockaid banner to render, by mobile's priority cascade + // (useReviewSecuritySummary): tx-malicious > tx-suspicious > token-malicious + // > token-suspicious > any unable-to-scan. Critically, a flagged TOKEN + // outranks a tx that merely couldn't be scanned (common on mainnet when the + // scan is absent), so we don't downgrade a malicious-token warning to the + // soft "proceed with caution". Only the tx banner opens the expandable pane. + const blockaidBannerKind: "tx" | "token" | null = (() => { + if ( + txSecurityLevel === SecurityLevel.MALICIOUS || + txSecurityLevel === SecurityLevel.SUSPICIOUS + ) { + return "tx"; + } + if ( + tokenWarningLevel === SecurityLevel.MALICIOUS || + tokenWarningLevel === SecurityLevel.SUSPICIOUS + ) { + return "token"; + } + if (txSecurityLevel && paneConfig.blockaidIndex !== null) { + return "tx"; // tx unable-to-scan + } + if (tokenWarningMessage) { + return "token"; // token unable-to-scan only + } + return null; + })(); + const isOnBlockaidPane = paneConfig.blockaidIndex !== null && activePaneIndex === paneConfig.blockaidIndex; @@ -421,11 +449,10 @@ export const ReviewTx = ({
- {/* Exactly one Blockaid banner, by priority. The transaction-scan - banner (which opens the expandable Blockaid pane) outranks the token - verdict; when the transaction itself isn't flagged we fall back to a - single consolidated token banner. */} - {txSecurityLevel && paneConfig.blockaidIndex !== null ? ( + {/* Exactly one Blockaid banner, chosen by blockaidBannerKind (mobile + priority). The tx-scan banner opens the expandable pane; the token + banner is a single consolidated warning. */} + {blockaidBannerKind === "tx" ? ( { @@ -434,7 +461,7 @@ export const ReviewTx = ({ } }} /> - ) : tokenWarningMessage ? ( + ) : blockaidBannerKind === "token" ? (
) : null} From 24bc94564103024419c042898722ef2c8752ee81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 07:06:49 -0300 Subject: [PATCH 108/121] Re-measure the amount input width when the font-size class changes 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 --- .../src/popup/components/amount/AmountCard/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index cb3e4cb112..704c3c2437 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -78,17 +78,21 @@ export const AmountCard = ({ const [inputWidthCrypto, setInputWidthCrypto] = useState(0); const [inputWidthFiat, setInputWidthFiat] = useState(0); + // Re-measure on font-class changes too (not just value changes): the + // read-only receive card's inputType flips from the sell card's toggle + // without its own value changing, so a font-size-bucket change would + // otherwise leave a stale width and clip the value. useLayoutEffect(() => { if (cryptoSpanRef.current) { setInputWidthCrypto(cryptoSpanRef.current.offsetWidth + 2); } - }, [amount]); + }, [amount, amountFontSizeClass]); useLayoutEffect(() => { if (fiatSpanRef.current) { setInputWidthFiat(fiatSpanRef.current.offsetWidth + 4); } - }, [amountUsd]); + }, [amountUsd, amountFontSizeClass]); const isSuspicious = securityLevel === SecurityLevel.MALICIOUS || From 80df6d6599053c0679e9f64b4ecdfc21218b9be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 09:34:33 -0300 Subject: [PATCH 109/121] Render the trustline explanation in-flow so it isn't clipped on the review The previous fix hid the review body by unmounting the MultiPaneSlider, but the trustline sheet was a nested position:fixed SlideupModal inside the review's own self-measuring SlideupModal. With the slider gone the review modal's measured height collapsed to ~0 and overflow:hidden clipped the trustline sheet down to just its "Got it" button. Render the trustline explanation as in-flow InfoSheetContent that replaces the review body while open (and is replaced by the body on close). In-flow content drives the modal's height, so it shows full-size as the only visible sheet with no ghost behind it. Co-Authored-By: Claude Opus 4.8 --- .../components/TrustlineInfoSheet.tsx | 19 +++++++++++------- .../__tests__/TrustlineInfoSheet.test.tsx | 6 +++--- .../ReviewTransaction/index.tsx | 20 +++++++++---------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx index d609c3c406..b95ff6deb1 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx @@ -2,36 +2,41 @@ import React from "react"; import { Trans, useTranslation } from "react-i18next"; import { Icon } from "@stellar/design-system"; -import { InfoBottomSheet } from "popup/components/InfoBottomSheet"; +import { InfoSheetContent } from "popup/components/InfoBottomSheet"; interface TrustlineInfoSheetProps { - isOpen: boolean; tokenCode: string; onClose: () => void; } +/** + * Trustline explanation rendered IN-FLOW (not a nested SlideupModal). It sits + * inside the review sheet in place of the review body while open: nesting a + * position:fixed SlideupModal inside the self-measuring review modal collapsed + * the review modal's height and clipped the sheet down to just its action + * button (§ batch3 task 4). In-flow content drives the modal's height, so it + * renders full-size as the only visible sheet. + */ export const TrustlineInfoSheet = ({ - isOpen, tokenCode, onClose, }: TrustlineInfoSheetProps) => { const { t } = useTranslation(); return ( - } title={t("This will add a trustline to {{code}}", { code: tokenCode })} actionLabel={t("Got it")} + onClose={onClose} > }} /> - + ); }; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx index 0c4f38cedc..ff52dba432 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx @@ -50,7 +50,7 @@ describe("TrustlineInfoSheet", () => { const onClose = jest.fn(); render( - + , ); expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); @@ -63,7 +63,7 @@ describe("TrustlineInfoSheet", () => { const onClose = jest.fn(); render( - + , ); // Body copy is token-specific and present. @@ -77,7 +77,7 @@ describe("TrustlineInfoSheet", () => { const onClose = jest.fn(); render( - + , ); fireEvent.click(screen.getByTestId("trustline-info-sheet-close")); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 4740217954..7117b84af8 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -619,18 +619,18 @@ export const ReviewTx = ({ /> ) : (
- {/* Hide the review body while the trustline info sheet is up so the - review sheet doesn't show as a ghost behind it; closing the sheet - restores it (§ task 5). Matches the existing ActionButtons guard - below. */} - {!isOnTrustlinePane && ( + {/* The trustline explanation replaces the review body in-flow while + open (no ghost behind it), and the body returns when it closes. + Rendered in-flow rather than as a nested modal so it isn't clipped + by the self-measuring review modal (§ batch3 task 4). */} + {isOnTrustlinePane ? ( + setIsOnTrustlinePane(false)} + /> + ) : ( )} - setIsOnTrustlinePane(false)} - /> {!isOnFeesPane && !isOnTrustlinePane && (
Date: Mon, 29 Jun 2026 09:35:18 -0300 Subject: [PATCH 110/121] Shrink the swap direction toggle so it clears the fiat-toggle button The 36px direction chevron overlapped the sell card's RefreshCw03 fiat toggle. Drop it to 33px so the two no longer collide. Co-Authored-By: Claude Opus 4.8 --- extension/src/popup/components/swap/SwapAmount/styles.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index b2a69a272c..e5c7f6ad93 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -51,8 +51,10 @@ display: flex; align-items: center; justify-content: center; - width: pxToRem(36px); - height: pxToRem(36px); + // Slightly smaller than the cards' gap so it doesn't overlap the sell + // card's fiat-toggle (RefreshCw03) button (§ batch3 task 9). + width: pxToRem(33px); + height: pxToRem(33px); padding: 0; border: none; border-radius: pxToRem(100px); From 4d54e6c14d3a224f8ce97b474672d65cb8b92452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 09:45:51 -0300 Subject: [PATCH 111/121] Disable the swap "Enter an amount" CTA while the sell input is focused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With both tokens picked and no amount, the CTA reads "Enter an amount" and taps to focus the sell input. On the extension there's no virtual keyboard, so once the input is focused that tap target is redundant — disable the CTA while the sell input is focused and re-enable it on blur (so the affordance returns if the user clicks away still empty). This is layered in SwapAmount; the shared getSwapCtaState state machine stays mobile-aligned. AmountCard gains optional onInputFocus/onInputBlur callbacks. Co-Authored-By: Claude Opus 4.8 --- .../AmountCard/__tests__/index.test.tsx | 33 ++++++++++ .../components/amount/AmountCard/index.tsx | 10 +++ .../__tests__/SwapAmount.ctaGate.test.tsx | 66 +++++++++++++++++-- .../components/swap/SwapAmount/index.tsx | 11 +++- 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index 6d57b9681e..13b1be13a9 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -142,6 +142,39 @@ describe("AmountCard", () => { expect(input.selectionStart).toBe(input.selectionEnd); }); + it("fires onInputFocus/onInputBlur when the amount input gains/loses focus", () => { + const onInputFocus = jest.fn(); + const onInputBlur = jest.fn(); + render( + + + , + ); + const input = screen.getByTestId("send-amount-amount-input"); + fireEvent.focus(input); + expect(onInputFocus).toHaveBeenCalledTimes(1); + fireEvent.blur(input); + expect(onInputBlur).toHaveBeenCalledTimes(1); + }); + + it("does not throw on focus/blur when the callbacks are omitted", () => { + render( + + + , + ); + const input = screen.getByTestId("send-amount-amount-input"); + expect(() => { + fireEvent.focus(input); + fireEvent.blur(input); + }).not.toThrow(); + }); + it("fires onSelectAsset when asset selector is clicked even with isReadOnly", () => { const onSelectAsset = jest.fn(); render( diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 704c3c2437..8a83f4eb7a 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -34,6 +34,10 @@ export interface AmountCardProps { /** Optional handle to the amount input so a parent can focus it (e.g. the * swap "Enter an amount" CTA focuses the sell card). */ amountInputRef?: React.RefObject; + /** Fired when the amount input gains/loses focus, so a parent can track it + * (e.g. the swap CTA disables itself while the sell input is focused). */ + onInputFocus?: () => void; + onInputBlur?: () => void; cryptoDecimals: number; onAmountChange: (next: { amount: string; newCursor: number }) => void; onAmountUsdChange: (next: { amount: string; newCursor: number }) => void; @@ -60,6 +64,8 @@ export const AmountCard = ({ isReadOnly = false, autoFocus = true, amountInputRef, + onInputFocus, + onInputBlur, cryptoDecimals, onAmountChange, onAmountUsdChange, @@ -174,6 +180,8 @@ export const AmountCard = ({ input.selectionEnd = next.newCursor; }); }} + onFocus={onInputFocus} + onBlur={onInputBlur} autoFocus={autoFocus} autoComplete="off" /> @@ -210,6 +218,8 @@ export const AmountCard = ({ input.selectionEnd = next.newCursor; }); }} + onFocus={onInputFocus} + onBlur={onInputBlur} autoFocus={autoFocus} autoComplete="off" /> diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx index 836147dacf..4889a81913 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx @@ -165,15 +165,18 @@ describe("SwapAmount CTA gate", () => { ); const btn = screen.getByTestId("swap-amount-btn-continue"); - expect(btn).toBeEnabled(); expect(btn).toHaveTextContent("Enter an amount"); - // The sell card auto-focuses on mount; blur it so we can prove the CTA tap - // re-focuses it rather than submitting. - (document.activeElement as HTMLElement | null)?.blur(); + // The sell card auto-focuses on mount (→ CTA disabled, see below); blur it + // so the CTA enables and we can prove the tap re-focuses it. const sellInput = within(screen.getByTestId("swap-sell-card")).getByTestId( "send-amount-amount-input", ); + // Real DOM blur moves activeElement; fireEvent.blur fires React's onBlur so + // the CTA re-enables. (jsdom's .blur() doesn't dispatch the synthetic blur.) + sellInput.blur(); + fireEvent.blur(sellInput); + expect(btn).toBeEnabled(); expect(sellInput).not.toHaveFocus(); fireEvent.click(btn); @@ -182,6 +185,61 @@ describe("SwapAmount CTA gate", () => { expect(goToNext).not.toHaveBeenCalled(); }); + it("disables the 'Enter an amount' CTA while the sell input is focused and re-enables it on blur", () => { + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(false); + render( + + + , + ); + + const btn = screen.getByTestId("swap-amount-btn-continue"); + const sellInput = within(screen.getByTestId("swap-sell-card")).getByTestId( + "send-amount-amount-input", + ); + + fireEvent.focus(sellInput); + expect(btn).toBeDisabled(); + expect(btn).toHaveTextContent("Enter an amount"); + + fireEvent.blur(sellInput); + expect(btn).toBeEnabled(); + expect(btn).toHaveTextContent("Enter an amount"); + }); + it("disables the CTA with a fee warning when a non-XLM swap lacks XLM for fees", async () => { jest .spyOn(XlmReserve, "shouldShowXlmReservePreflight") diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index d9b584dac6..8bdf7fe91a 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -197,6 +197,11 @@ export const SwapAmount = ({ // True while a live best-path quote is in flight, so the CTA can tell // "still loading a quote" apart from "no path exists" (§2.5). const [isLiveQuoteLoading, setIsLiveQuoteLoading] = useState(false); + // Tracks focus on the sell input so the "Enter an amount" CTA can disable + // itself while the input is focused. Unlike mobile (which keeps it enabled to + // re-summon the keyboard), the extension has no virtual keyboard — once the + // input is focused the tap-to-focus affordance is redundant (§ batch3 task 1). + const [isSellInputFocused, setIsSellInputFocused] = useState(false); const handleContinue = async (values: { amount: string }) => { // Retrying after a quote-expiry submit failure: dismiss the stale notice @@ -777,7 +782,9 @@ export const SwapAmount = ({ isRounded variant="secondary" isLoading={simulationState.state === RequestState.LOADING} - disabled={cta.disabled} + disabled={ + cta.disabled || (cta.labelKey === "enter" && isSellInputFocused) + } onClick={(e) => { e.preventDefault(); // In the "select" state the button is a shortcut to the picker @@ -841,6 +848,8 @@ export const SwapAmount = ({ // unfocused with a gray "0" placeholder (§ task 1). autoFocus={!!asset && !!destinationAsset} amountInputRef={sellInputRef} + onInputFocus={() => setIsSellInputFocused(true)} + onInputBlur={() => setIsSellInputFocused(false)} assetCode={srcAsset ? srcAsset.code : ""} assetIcon={assetIcon} assetIcons={ From fee8cec38d097d34c785a7fca077b389ca4285e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 09:52:47 -0300 Subject: [PATCH 112/121] Let the fiat amount input be fully erased to empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cleared amount ran through formatAmountPreserveCursor, where Number("") === 0 formatted back to "0" — so the fiat field couldn't be emptied and showed a forced, non-erasable "0" after the "$". Return an empty amount for a fully-cleared field instead; callers already coerce "" to their canonical zero for calculations, and the swap sell card blanks the display, so the fiat box now shows just "$". The crypto side is unaffected (its "0" placeholder still renders) and Send tolerates an empty amount (validity checks gate the CTA). Co-Authored-By: Claude Opus 4.8 --- .../popup/helpers/__tests__/formatters.test.ts | 15 +++++++++++++++ extension/src/popup/helpers/formatters.ts | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/extension/src/popup/helpers/__tests__/formatters.test.ts b/extension/src/popup/helpers/__tests__/formatters.test.ts index 556e9930f9..717655d5a5 100644 --- a/extension/src/popup/helpers/__tests__/formatters.test.ts +++ b/extension/src/popup/helpers/__tests__/formatters.test.ts @@ -1,5 +1,6 @@ import BigNumber from "bignumber.js"; import { + formatAmountPreserveCursor, getValidBigNumber, isValidPositiveAmount, normalizeNumericString, @@ -136,3 +137,17 @@ describe("trimTrailingZeros", () => { expect(trimTrailingZeros("1000000.0000")).toBe("1000000"); }); }); + +describe("formatAmountPreserveCursor", () => { + it("returns an empty amount for a fully-cleared field (not '0')", () => { + // Erasing every digit must stay empty so the input can show its placeholder + // / just the "$" prefix, rather than snapping back to a non-erasable "0". + expect(formatAmountPreserveCursor("", "12").amount).toBe(""); + expect(formatAmountPreserveCursor("", "1.23").amount).toBe(""); + }); + + it("still formats a non-empty amount", () => { + expect(formatAmountPreserveCursor("12", "1").amount).toBe("12"); + expect(formatAmountPreserveCursor("1.23", "1.2", 2).amount).toBe("1.23"); + }); +}); diff --git a/extension/src/popup/helpers/formatters.ts b/extension/src/popup/helpers/formatters.ts index b4f28ae940..50d464003c 100644 --- a/extension/src/popup/helpers/formatters.ts +++ b/extension/src/popup/helpers/formatters.ts @@ -86,6 +86,12 @@ export const formatAmountPreserveCursor = ( const decimal = new Intl.NumberFormat("en-US", { style: "decimal" }); const maxDigits = 12; const cleaned = cleanAmount(val); + // A fully-cleared field stays empty rather than snapping to "0" (Number("") + // is 0, which would force a non-erasable "0"). Callers coerce "" to their + // canonical zero ("0"/"0.00") while the input shows its gray placeholder. + if (cleaned === "") { + return { amount: "", newCursor: 0 }; + } // add commas to pre decimal digits if (cleaned.indexOf(".") !== -1) { const parts = cleaned.split("."); From d08d298e2ba6e3e79b9457553d1acbe535913667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 09:59:29 -0300 Subject: [PATCH 113/121] Use mobile's insufficient-balance copy with the max spendable amount Replace "You don't have enough {{asset}} in your account" with mobile's "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", feeding the shared AmountCard the formatted spendable balance (displayTotal) from both the swap and send screens. en + pt updated with parity coverage. Co-Authored-By: Claude Opus 4.8 --- .../amount/AmountCard/__tests__/index.test.tsx | 10 +++++++--- .../popup/components/amount/AmountCard/index.tsx | 14 +++++++++++--- .../src/popup/components/send/SendAmount/index.tsx | 1 + .../src/popup/components/swap/SwapAmount/index.tsx | 1 + .../locales/__tests__/translationParity.test.ts | 1 + .../Insufficient balance. Maximum spendable.json | 3 +++ extension/src/popup/locales/en/translation.json | 2 +- .../Insufficient balance. Maximum spendable.json | 3 +++ extension/src/popup/locales/pt/translation.json | 2 +- 9 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 extension/src/popup/locales/en/Insufficient balance. Maximum spendable.json create mode 100644 extension/src/popup/locales/pt/Insufficient balance. Maximum spendable.json diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index 13b1be13a9..c5bb7be169 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -63,14 +63,18 @@ describe("AmountCard", () => { expect(screen.getByTestId("ScamAssetIcon")).toBeInTheDocument(); }); - it("renders the too-high error when isAmountTooHigh is true", () => { + it("renders the insufficient-balance error when isAmountTooHigh is true", () => { render( - + , ); + // The test i18n returns the key un-interpolated; assert the new copy is in + // use (the max-spendable amount + symbol are interpolated at runtime). expect( - screen.getByText("You don’t have enough {{asset}} in your account"), + screen.getByText( + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", + ), ).toBeInTheDocument(); }); diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 8a83f4eb7a..84c245a6a0 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -29,6 +29,9 @@ export interface AmountCardProps { supportsUsd: boolean; fiatLineText: string; isAmountTooHigh: boolean; + /** Pre-formatted max-spendable amount shown in the insufficient-balance + * error (e.g. "123.23"); the token code is taken from assetCode. */ + maxSpendableText?: string; isReadOnly?: boolean; autoFocus?: boolean; /** Optional handle to the amount input so a parent can focus it (e.g. the @@ -61,6 +64,7 @@ export const AmountCard = ({ supportsUsd, fiatLineText, isAmountTooHigh, + maxSpendableText = "", isReadOnly = false, autoFocus = true, amountInputRef, @@ -291,9 +295,13 @@ export const AmountCard = ({
- {t("You don’t have enough {{asset}} in your account", { - asset: assetCode, - })} + {t( + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", + { + amount: maxSpendableText, + symbol: assetCode, + }, + )}
)} diff --git a/extension/src/popup/components/send/SendAmount/index.tsx b/extension/src/popup/components/send/SendAmount/index.tsx index e4cb123205..70dcff0126 100644 --- a/extension/src/popup/components/send/SendAmount/index.tsx +++ b/extension/src/popup/components/send/SendAmount/index.tsx @@ -810,6 +810,7 @@ export const SendAmount = ({ : `${formatAmount(effectiveTokenAmount || "0")} ${parsedSourceAsset.code}` } isAmountTooHigh={isAmountTooHigh} + maxSpendableText={displayTotal} cryptoDecimals={getAssetDecimals( asset, sendData.userBalances, diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 8bdf7fe91a..04bf63f0c4 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -873,6 +873,7 @@ export const SwapAmount = ({ }` } isAmountTooHigh={isAmountTooHigh} + maxSpendableText={displayTotal} cryptoDecimals={assetDecimals} onAmountChange={({ amount: newAmount }) => { // Normalize a cleared input back to the canonical "0". diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 1b47c7369a..a89c1d40af 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -10,6 +10,7 @@ const swapKeys = [ "You sell", "You receive", "Insufficient balance", + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", "Not enough XLM for network fees", "No quote available", "The token you're receiving was flagged as malicious by Blockaid.", diff --git a/extension/src/popup/locales/en/Insufficient balance. Maximum spendable.json b/extension/src/popup/locales/en/Insufficient balance. Maximum spendable.json new file mode 100644 index 0000000000..c7ffc5d32a --- /dev/null +++ b/extension/src/popup/locales/en/Insufficient balance. Maximum spendable.json @@ -0,0 +1,3 @@ +{ + " {{amount}} {{symbol}}": " {{amount}} {{symbol}}" +} diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index c7e5ffda81..d82871be90 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -319,6 +319,7 @@ "Inflation Destination": "Inflation Destination", "Insufficient balance": "Insufficient balance", "Insufficient Balance": "Insufficient Balance", + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}": "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", "Insufficient Fee": "Insufficient Fee", "INSUFFICIENT FUNDS FOR FEE": "INSUFFICIENT FUNDS FOR FEE", "Introducing Freighter Mobile": "Introducing Freighter Mobile", @@ -781,7 +782,6 @@ "You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.": "You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.", "You can close this screen, your transaction should be complete in less than a minute.": "You can close this screen, your transaction should be complete in less than a minute.", "You can define your own assets lists in Settings.": "You can define your own assets lists in Settings.", - "You don’t have enough {{asset}} in your account": "You don’t have enough {{asset}} in your account", "You have no assets added.": "You have no assets added.", "You have no collectibles added.": "You have no collectibles added.", "You may enable connection to domains that do not use an SSL certificate in Settings > Security > Advanced settings.": "You may enable connection to domains that do not use an SSL certificate in Settings > Security > Advanced settings.", diff --git a/extension/src/popup/locales/pt/Insufficient balance. Maximum spendable.json b/extension/src/popup/locales/pt/Insufficient balance. Maximum spendable.json new file mode 100644 index 0000000000..c7ffc5d32a --- /dev/null +++ b/extension/src/popup/locales/pt/Insufficient balance. Maximum spendable.json @@ -0,0 +1,3 @@ +{ + " {{amount}} {{symbol}}": " {{amount}} {{symbol}}" +} diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index c8e72dbd55..6686f06665 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -319,6 +319,7 @@ "Inflation Destination": "Destino de Inflação", "Insufficient balance": "Saldo insuficiente", "Insufficient Balance": "Saldo Insuficiente", + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}": "Saldo insuficiente. Máximo disponível: {{amount}} {{symbol}}", "Insufficient Fee": "Taxa Insuficiente", "INSUFFICIENT FUNDS FOR FEE": "FUNDOS INSUFICIENTES PARA TAXA", "Introducing Freighter Mobile": "Apresentando Freighter Mobile", @@ -779,7 +780,6 @@ "You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.": "Você pode escolher mesclar sua conta atual nas novas contas após a migração, o que efetivamente destruirá sua conta atual.", "You can close this screen, your transaction should be complete in less than a minute.": "Você pode fechar esta tela, sua transação deve estar completa em menos de um minuto.", "You can define your own assets lists in Settings.": "Você pode definir suas próprias listas de ativos nas Configurações.", - "You don’t have enough {{asset}} in your account": "Você não tem {{asset}} suficiente em sua conta", "You have no assets added.": "Você não tem ativos adicionados.", "You have no collectibles added.": "Você não tem colecionáveis adicionados.", "You may enable connection to domains that do not use an SSL certificate in Settings > Security > Advanced settings.": "Você pode habilitar a conexão com domínios que não usam um certificado SSL em Configurações > Segurança > Configurações avançadas.", From 159efcc5df0a99bef99f230df4a5c02a26ab31e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 10:01:30 -0300 Subject: [PATCH 114/121] Fetch swap token prices for held + selected tokens so prices survive a re-quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swap price fetch passed destinationBalances as the balances to price, but the swap flow never sets a destination account, so that was always empty — on a stale-cache miss (e.g. returning to the amount screen after a quote expiry) no price was fetched and both cards showed "--" until the user changed the source token (which refreshed the cache as a side effect). Price the account's held balances instead, and pass the selected source + destination canonicals as explicit additional ids, so prices repopulate without a token change. Co-Authored-By: Claude Opus 4.8 --- .../SwapAmount/hooks/useGetSwapAmountData.tsx | 15 +++++++++++---- .../popup/components/swap/SwapAmount/index.tsx | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx index 2f14cf958f..53e55c5c9e 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx @@ -37,6 +37,7 @@ function useGetSwapAmountData( }, destinationAddress?: string, // NOTE: can be a G/C/M address destinationAsset?: string, // canonical of the selected destination token + sourceAsset?: string, // canonical of the selected source token ) { const [state, dispatch] = useReducer( reducer, @@ -85,11 +86,17 @@ function useGetSwapAmountData( if (_isMainnet) { const fetchedTokenPrices = await fetchTokenPrices({ publicKey: userDomains.publicKey, - balances: destinationBalances.balances, + // Price the account's HELD balances (the swap never sets a + // destination account, so destinationBalances is empty — pricing it + // fetched nothing, which left the source price "--" on a stale-cache + // miss after a quote expiry, § batch3 task 5). + balances: userDomains.balances.balances, useCache: true, - // Price the selected destination token too, even when the account - // doesn't hold it — mirrors freighter-mobile's extraTokenIds. - additionalAssetIds: destinationAsset ? [destinationAsset] : [], + // Price the selected source + destination tokens explicitly, even + // when the account doesn't hold them — mirrors mobile's extraTokenIds. + additionalAssetIds: [sourceAsset, destinationAsset].filter( + (id): id is string => Boolean(id), + ), }); tokenPrices = fetchedTokenPrices.tokenPrices || {}; } diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 04bf63f0c4..6c6d68b9c4 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -170,6 +170,7 @@ export const SwapAmount = ({ }, destination, destinationAsset, + asset, ); const { state: simulationState, From ca3cf4f8a5c8faf785d4511b8c828ec26f9773c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 10:03:38 -0300 Subject: [PATCH 115/121] Recalculate the fiat amount when swapping for the 0.5 XLM reserve "Swap for 0.5 XLM" prefilled only the crypto amount (saveAmount), so in fiat mode the screen kept using the stale amountUsd that drives the display, CTA gate and submit. Also recalculate amountUsd from the sell token's price; if that token has no USD price, drop to crypto mode so the prefilled amount is the one used. Co-Authored-By: Claude Opus 4.8 --- .../popup/components/swap/SwapAmount/index.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 6c6d68b9c4..17d347385a 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -662,6 +662,22 @@ export const SwapAmount = ({ new BigNumber(sellSpendable), ); dispatch(saveAmount(capped.toFixed(7))); + // In fiat mode the whole pipeline reads amountUsd, so also recalculate + // the fiat figure from the sell token's price; if it has no price, drop + // to crypto mode so the prefilled amount is the one used (§ batch3 t8). + if (assetPrice) { + dispatch( + saveAmountUsd( + formatAmount( + roundUsdValue( + capped.multipliedBy(new BigNumber(assetPrice)).toString(), + ), + ), + ), + ); + } else if (inputType === "fiat") { + setInputType("crypto"); + } } } catch (e) { // No path / network error — leave the amount as-is for manual entry. From 1c2e3d54f393ddef84c56d5f1107154224ad0918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 29 Jun 2026 10:09:04 -0300 Subject: [PATCH 116/121] Polish the XLM-reserve sheet buttons + icon colors toward Figma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump both action buttons from md to lg (Figma's 12/8 padding). - "Swap for 0.5 XLM" becomes tertiary and "Copy my wallet address" stays secondary (matches mobile). - Stretch the CopyText wrapper chain so the copy button is full-width like the swap button. - Mute the plus-circle badge icon (lilac-09) and the close icon (gray-09) to the Brand/Default Foreground tones Figma uses, instead of the brighter -11. Body copy left at 14px/20px secondary gray — verified against Figma node 8641-33470, which matches the current values. Co-Authored-By: Claude Opus 4.8 --- .../components/swap/XlmReserveSheet/index.tsx | 6 +++--- .../components/swap/XlmReserveSheet/styles.scss | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/extension/src/popup/components/swap/XlmReserveSheet/index.tsx b/extension/src/popup/components/swap/XlmReserveSheet/index.tsx index 6efd95ab3b..de2b10ade6 100644 --- a/extension/src/popup/components/swap/XlmReserveSheet/index.tsx +++ b/extension/src/popup/components/swap/XlmReserveSheet/index.tsx @@ -88,8 +88,8 @@ export const XlmReserveSheet = ({
{canSwapForReserve ? (