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/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", + ), + ), + }, }; 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); +}); 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..6a3741f0f0 --- /dev/null +++ b/extension/specs/swap-to-new-token-design.md @@ -0,0 +1,729 @@ +# 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 ([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 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 requiresTrustline?} + + 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). 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 + +- **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. +- **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 + +- **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. + +**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 +`{ 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. + +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 + 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 + 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 → `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 +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.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 requiresTrustline +op[1] = Operation.pathPaymentStrictSend({ sendAsset, sendAmount, destination: self, destAsset, destMin, path }) +``` + +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 = 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. + +**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.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. +- **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` / `requiresTrustline`) +- **destination** selected (`{ tokenCode, tokenIssuer, requiresTrustline, 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`: `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. + - `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) | 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/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index ca1da1a991..ac1387ad9b 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -10,7 +10,9 @@ import * as ApiInternal from "@shared/api/internal"; describe("useGetTokenPrices", () => { afterEach(() => { - jest.clearAllMocks(); + // restore (not just clear) so an unconsumed mockImplementationOnce from a + // cache-hit test doesn't leak its queued impl into the next test. + jest.restoreAllMocks(); }); it("should return token prices from API with no cache", async () => { const getTokenPricesSpy = jest @@ -217,4 +219,151 @@ describe("useGetTokenPrices", () => { expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({}); }); + it("fetches a missing additionalAssetId separately and merges it with cached balance prices", async () => { + // The separate extra-fetch returns only the destination price. + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementationOnce(() => + Promise.resolve({ + "USDC:GUSD": { currentPrice: "1", percentagePriceChange24h: "0" }, + }), + ); + // Valid cache that only covers native — the extra destination id is missing. + const preloadedState = { + cache: { + tokenPrices: { + G123: { + native: { currentPrice: "1", percentagePriceChange24h: ".5" }, + updatedAt: Date.now(), + }, + }, + }, + }; + + const store = makeDummyStore(preloadedState); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + await act(async () => { + await result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + useCache: true, + additionalAssetIds: ["USDC:GUSD"], + } as any); + }); + // Balance prices came from cache; only the missing destination was fetched. + expect(getTokenPricesSpy).toHaveBeenCalledWith(["USDC:GUSD"]); + expect(result.current.state.data?.tokenPrices).toEqual({ + native: { currentPrice: "1", percentagePriceChange24h: ".5" }, + "USDC:GUSD": { currentPrice: "1", percentagePriceChange24h: "0" }, + }); + }); + it("keeps the balance prices when the additionalAssetIds fetch fails", async () => { + // First call (balances) succeeds; the separate extra-fetch rejects. + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementationOnce(() => + Promise.resolve({ + native: { currentPrice: "1", percentagePriceChange24h: ".5" }, + }), + ) + .mockImplementationOnce(() => Promise.reject(new Error("boom"))); + const preloadedState = { cache: { tokenPrices: {} } }; + + const store = makeDummyStore(preloadedState); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + await act(async () => { + await result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + useCache: true, + additionalAssetIds: ["USDC:GUSD"], + } as any); + }); + // The destination fetch failing must NOT wipe the balance prices. + expect(getTokenPricesSpy).toHaveBeenNthCalledWith(1, ["native"]); + expect(getTokenPricesSpy).toHaveBeenNthCalledWith(2, ["USDC:GUSD"]); + expect(result.current.state.data?.tokenPrices).toEqual({ + native: { currentPrice: "1", percentagePriceChange24h: ".5" }, + }); + }); + it("uses the cache when the additionalAssetIds are already cached", async () => { + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementationOnce(() => + Promise.resolve({ + native: { currentPrice: "9", percentagePriceChange24h: "9" }, + }), + ); + const preloadedState = { + cache: { + tokenPrices: { + G123: { + native: { currentPrice: "1", percentagePriceChange24h: ".5" }, + "USDC:GUSD": { currentPrice: "1", percentagePriceChange24h: "0" }, + updatedAt: Date.now(), + }, + }, + }, + }; + + const store = makeDummyStore(preloadedState); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + await act(async () => { + await result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + useCache: true, + additionalAssetIds: ["USDC:GUSD"], + } as any); + }); + expect(getTokenPricesSpy).not.toHaveBeenCalled(); + }); }); diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index ff2d3074a0..c24748a252 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -28,10 +28,14 @@ export function useGetTokenPrices() { publicKey, balances, useCache = false, + additionalAssetIds = [], }: { publicKey: string; balances: AccountBalances["balances"]; useCache: boolean; + // Extra canonicals to price beyond the account's balances — e.g. a swap + // destination token the account doesn't hold yet. + additionalAssetIds?: string[]; }): Promise => { dispatch({ type: "FETCH_DATA_START" }); @@ -74,6 +78,31 @@ export function useGetTokenPrices() { payload.tokenPrices = null; } } + + // Best-effort: additively price any requested extra ids (e.g. a non-held + // swap destination) that the balance prices don't already cover. This runs + // as a SEPARATE request and swallows its own errors so a failure here can + // never wipe the (reliable) balance prices — the destination just falls back + // to its stellar.expert spot price downstream. + if (payload.tokenPrices) { + const resolved = payload.tokenPrices; + const missingExtra = additionalAssetIds.filter((id) => !(id in resolved)); + if (missingExtra.length) { + try { + const extraPrices = await getTokenPrices(missingExtra); + const mergedTokenPrices = { ...resolved, ...extraPrices }; + reduxDispatch( + saveTokenPrices({ publicKey, tokenPrices: mergedTokenPrices }), + ); + payload.tokenPrices = mergedTokenPrices; + } catch (e) { + captureException( + `Failed to fetch additional token prices in useGetTokenPrices - ${e}`, + ); + } + } + } + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); return payload; }; diff --git a/extension/src/popup/components/AssetListRow/index.tsx b/extension/src/popup/components/AssetListRow/index.tsx new file mode 100644 index 0000000000..50d09e724d --- /dev/null +++ b/extension/src/popup/components/AssetListRow/index.tsx @@ -0,0 +1,84 @@ +import React from "react"; + +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { formatDomain, getCanonicalFromAsset } from "helpers/stellar"; +import { truncateString } from "helpers/stellar"; + +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). */ + iconUrl?: string | null; + /** Renders the Blockaid scam badge on the icon when true. */ + isSuspicious?: boolean; + /** Slot rendered on the right of the row (e.g. an "Add" button or a menu). */ + rightElement?: React.ReactNode; + /** Click handler for the row body (icon + code + domain). */ + onClick?: () => void; + "data-testid"?: string; + /** Optional testids that mirror legacy markup so existing tests keep working. */ + bodyTestId?: string; + codeTestId?: string; + domainTestId?: string; +} + +/** + * Shared presentational token-list row: icon + token code + domain subtitle on + * the left, with a caller-provided element on the right. Used by the + * Add-a-token flow ("Add +" button) and the Swap destination picker (menu). + */ +export const AssetListRow = ({ + code, + displayCode, + issuer = "", + domain, + iconUrl, + isSuspicious = false, + rightElement, + onClick, + "data-testid": dataTestId, + bodyTestId, + codeTestId, + domainTestId, +}: AssetListRowProps) => { + const canonical = + code === "XLM" && !issuer ? "native" : getCanonicalFromAsset(code, issuer); + const label = displayCode ?? code; + const displayLabel = label.length > 20 ? truncateString(label) : label; + + return ( +
+
+ +
+
+ {displayLabel} +
+ {domain ? ( +
+ {formatDomain(domain)} +
+ ) : null} +
+
+ {rightElement} +
+ ); +}; diff --git a/extension/src/popup/components/AssetListRow/styles.scss b/extension/src/popup/components/AssetListRow/styles.scss new file mode 100644 index 0000000000..1d0a09a836 --- /dev/null +++ b/extension/src/popup/components/AssetListRow/styles.scss @@ -0,0 +1,43 @@ +@use "../../styles/utils.scss" as *; + +.AssetListRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(8px); + width: 100%; + + &__body { + display: flex; + align-items: center; + flex-grow: 1; + min-width: 0; + cursor: pointer; + } + + &__info { + min-width: 0; + line-height: pxToRem(20px); + } + + // Regular-weight token code (not bold), matching the Add-a-token list. + &__code { + color: var(--sds-clr-gray-12); + font-size: pxToRem(16px); + font-weight: var(--font-weight-regular); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + // Muted domain subtitle with a little breathing room below the code. + &__domain { + margin-top: pxToRem(2px); + color: var(--sds-clr-gray-09); + font-size: var(--sds-fs-secondary); + line-height: pxToRem(18px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/extension/src/popup/components/BalanceRow/index.tsx b/extension/src/popup/components/BalanceRow/index.tsx new file mode 100644 index 0000000000..48a5bc25c9 --- /dev/null +++ b/extension/src/popup/components/BalanceRow/index.tsx @@ -0,0 +1,118 @@ +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"; + +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; + 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". 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 → "--". */ + 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, + retryAssetIconFetch, + amount, + fiatAmount, + percentChange, + onClick, + "data-testid": dataTestId, + amountTestId, + fiatTestId, + deltaTestId, +}: 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))) + : ""; + + return ( +
+
+ +
+ {code} +
+ {amount} +
+
+
+
+ {hasFiat && ( +
+ {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..fad26389bf --- /dev/null +++ b/extension/src/popup/components/BalanceRow/styles.scss @@ -0,0 +1,68 @@ +.BalanceRow { + 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; + + &--clickable { + cursor: pointer; + } + + &__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/InfoBottomSheet/index.tsx b/extension/src/popup/components/InfoBottomSheet/index.tsx new file mode 100644 index 0000000000..a51ee5d555 --- /dev/null +++ b/extension/src/popup/components/InfoBottomSheet/index.tsx @@ -0,0 +1,99 @@ +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; + /** Rendered in-flow (not in its own SlideupModal), e.g. inside the review + * sheet. Drops the sheet's self-padding (the host already provides the + * gutter) and matches the host's lg/rounded action button. */ + isInline?: boolean; +} + +/** + * 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, + isInline = false, +}: 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..891e4dcce0 --- /dev/null +++ b/extension/src/popup/components/InfoBottomSheet/styles.scss @@ -0,0 +1,94 @@ +@use "../../styles/utils.scss" as *; + +.InfoSheet { + display: flex; + flex-direction: column; + gap: pxToRem(24px); + padding: pxToRem(24px); + + // Rendered in-flow inside a host that already provides the gutter (e.g. the + // review sheet's View inset), so drop the self-padding to avoid doubling it. + &--inline { + padding: 0; + } + + &__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(--sds-fw-medium); + line-height: pxToRem(26px); + } + + &__body { + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-regular); + line-height: pxToRem(20px); + } + + // Inline emphasis within body copy (e.g. the trustline reserve amount): + // brighter and slightly heavier (500) than the regular body text. + &__emphasis { + color: var(--sds-clr-gray-12); + font-weight: var(--sds-fw-medium); + } +} 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..6d56cb77ef --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.security.test.tsx @@ -0,0 +1,359 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { RequestState } from "constants/request"; +import { BlockaidWarning, 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", + }, +}; + +// 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, + destWarnings, + sourceWarnings, +}: { + destLevel?: SecurityLevel; + sourceLevel?: SecurityLevel; + scanResult?: unknown; + networkDetails?: typeof swapProps.networkDetails; + destWarnings?: BlockaidWarning[]; + sourceWarnings?: BlockaidWarning[]; +}) => + render( + + + , + ); + +const renderWithDestLevel = (destLevel?: SecurityLevel) => + renderReview({ destLevel }); + +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); + 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 one suspicious token banner for a suspicious destination", () => { + renderWithDestLevel(SecurityLevel.SUSPICIOUS); + expect(screen.getByTestId("review-tx-token-warning")).toHaveTextContent( + "The token you're receiving was flagged as suspicious by Blockaid.", + ); + }); + + it("does not show a token warning or a badge when the token is safe", () => { + renderWithDestLevel(SecurityLevel.SAFE); + expect( + screen.queryByTestId("review-tx-token-warning"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("ScamAssetIcon")).not.toBeInTheDocument(); + }); + + 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-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("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.queryByTestId("review-tx-token-warning"), + ).not.toBeInTheDocument(); + }); + + it("shows the malicious-token banner even when the tx could not be scanned (token outranks unable-to-scan)", () => { + // 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(); + }); + + it("shows friendly Blockaid feature descriptions in the expanded pane, not the raw validation string", () => { + renderReview({ + scanResult: { + validation: { + result_type: "Malicious", + description: + "Token issuer
is flagged as malicious", + features: [ + { + type: "Malicious", + feature_id: "known_malicious", + description: + "An identified malicious address is associated with the token.", + }, + ], + }, + }, + }); + // Open the expandable Blockaid pane from the transaction banner. + fireEvent.click(screen.getByTestId("blockaid-malicious-label")); + expect( + screen.getByText( + "An identified malicious address is associated with the token.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/Token issuer
{ + renderReview({ + // Raw developer string with no per-feature descriptions — shown verbatim, + // matching mobile. + scanResult: { + validation: { + result_type: "Malicious", + description: + "Token issuer
is flagged as malicious", + }, + }, + destWarnings: [ + { + description: + "An identified malicious address is associated with the token.", + isError: true, + featureId: "known_malicious", + }, + ], + }); + fireEvent.click(screen.getByTestId("blockaid-malicious-label")); + // Both reasons appear together in the same list. + expect(screen.getByText(/Token issuer
{ + const shared = + "An identified malicious address is associated with the token."; + renderReview({ + // The tx scan already surfaces this exact friendly reason via a feature. + scanResult: { + validation: { + result_type: "Malicious", + description: "raw fallback", + features: [ + { + type: "Malicious", + feature_id: "known_malicious", + description: shared, + }, + ], + }, + }, + // Destination token carries the same reason — it must not appear twice. + destWarnings: [ + { description: shared, isError: true, featureId: "known_malicious" }, + ], + }); + fireEvent.click(screen.getByTestId("blockaid-malicious-label")); + expect(screen.getAllByText(shared)).toHaveLength(1); + }); + + it("submits directly from the 'Do not proceed' pane via 'Confirm anyway' (no 'Continue' bounce)", () => { + const onConfirm = jest.fn(); + render( + + + , + ); + // Open the "Do not proceed" pane from the malicious banner. + fireEvent.click(screen.getByTestId("blockaid-malicious-label")); + expect(screen.getByText("Do not proceed")).toBeInTheDocument(); + // The pane's action reads "Confirm anyway", not the old "Continue"... + expect(screen.queryByText("Continue")).not.toBeInTheDocument(); + // ...and clicking it confirms the transaction directly. + fireEvent.click(screen.getByText("Confirm anyway")); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("opens the pane from the token banner and lists token reasons when the tx scan is clean (token-only flag)", () => { + renderReview({ + // Clean / absent transaction scan — only the picked token is flagged, the + // common mainnet swap-to-a-bad-token case. + scanResult: null, + destLevel: SecurityLevel.MALICIOUS, + destWarnings: [ + { + description: + "An identified malicious address is associated with the token.", + isError: true, + featureId: "known_malicious", + }, + ], + }); + // The consolidated token banner is shown; clicking it opens the pane that + // lists the friendly token-scan reason (mobile parity). + fireEvent.click(screen.getByTestId("review-tx-token-warning")); + expect(screen.getByText("Do not proceed")).toBeInTheDocument(); + expect( + screen.getByText( + "An identified malicious address is associated with the token.", + ), + ).toBeInTheDocument(); + }); + + it("escalates the pane title to 'Suspicious Request' for a suspicious (non-malicious) token reason", () => { + renderReview({ + scanResult: null, + destLevel: SecurityLevel.SUSPICIOUS, + destWarnings: [ + { + description: "This token shows signs of suspicious activity.", + isError: false, + featureId: "suspicious_activity", + }, + ], + }); + fireEvent.click(screen.getByTestId("review-tx-token-warning")); + expect(screen.getByText("Suspicious Request")).toBeInTheDocument(); + expect(screen.queryByText("Do not proceed")).not.toBeInTheDocument(); + expect( + screen.getByText("This token shows signs of suspicious activity."), + ).toBeInTheDocument(); + }); + + it("lists the source token reason in the pane (source-only flag)", () => { + renderReview({ + scanResult: null, + sourceLevel: SecurityLevel.MALICIOUS, + sourceWarnings: [ + { + description: "The sending token is associated with a known scam.", + isError: true, + featureId: "source_scam", + }, + ], + }); + fireEvent.click(screen.getByTestId("review-tx-token-warning")); + expect( + screen.getByText("The sending token is associated with a known scam."), + ).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.swapRows.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.swapRows.test.tsx new file mode 100644 index 0000000000..6bde746500 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.swapRows.test.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { RequestState } from "constants/request"; +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", + }, +}; + +describe("ReviewTx swap rows", () => { + it("renders the Rate row (no destMin needed) computed from send/destination amounts", () => { + render( + + + , + ); + // 25 received / 10 sent => 2.5 per source unit + expect(screen.getByTestId("review-tx-rate").textContent).toContain("2.5"); + }); + + it("does not render a Minimum received row", () => { + render( + + + , + ); + expect( + screen.queryByTestId("review-tx-minimum-received"), + ).not.toBeInTheDocument(); + }); + + it("hides the Memo row on swaps (swaps carry no memo)", () => { + render( + + + , + ); + expect(screen.queryByTestId("review-tx-memo")).not.toBeInTheDocument(); + }); +}); 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..878af6b8d1 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx @@ -0,0 +1,97 @@ +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", + }, +}; + +describe("ReviewTx trustline banner", () => { + it("renders the banner when destination requires a trustline", () => { + render( + + + , + ); + // 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", () => { + render( + + + , + ); + expect( + screen.queryByTestId("review-tx-trustline-banner"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/ActionButtons.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/ActionButtons.tsx index b4cd88ea3c..043ea892cb 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/ActionButtons.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/ActionButtons.tsx @@ -12,9 +12,6 @@ interface ActionButtonsProps { shouldShowTxWarning: boolean; onCancel: () => void; onConfirmTx: () => void; - paneConfig: { - reviewIndex: number; - }; isSubmitDisabled: boolean; dstAsset?: { canonical: string; @@ -23,7 +20,6 @@ interface ActionButtonsProps { dest: Asset | { code: string; issuer: string } | null; asset: Asset | { code: string; issuer: string }; truncatedDest: string; - setActivePaneIndex?: (index: number) => void; } export const ActionButtons: React.FC = ({ @@ -35,17 +31,18 @@ export const ActionButtons: React.FC = ({ shouldShowTxWarning, onCancel, onConfirmTx, - paneConfig, isSubmitDisabled, dstAsset, dest, asset, truncatedDest, - setActivePaneIndex, }) => { const { t } = useTranslation(); - // 1. Blockaid pane: Cancel (primary) and Continue (text) to proceed to review + // 1. Blockaid "Do not proceed" pane: Cancel (primary) and "Confirm anyway" + // (text) which submits the transaction directly — matching mobile, where the + // security sheet's action confirms rather than bouncing back to review + // (§ batch4 task 9). if (isOnBlockaidPane) { return ( <> @@ -67,13 +64,13 @@ export const ActionButtons: React.FC = ({ className={`ReviewTx__TextAction ReviewTx__TextAction--${ isMalicious ? "error" : "default" }`} - data-testid="ContinueAction" + data-testid="SubmitAction" onClick={(e) => { e.preventDefault(); - setActivePaneIndex?.(paneConfig.reviewIndex); + onConfirmTx(); }} > - {t("Continue")} + {t("Confirm anyway")} ); 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/components/SwapRateRow.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx new file mode 100644 index 0000000000..28c879cd77 --- /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 { calculateSwapRate } from "../helpers/calculateSwapRate"; + +interface SwapRateRowProps { + srcCode: string; + dstCode: string; + sendAmount: string; + 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, + sendAmount, + destinationAmount, +}: SwapRateRowProps) => { + const { t } = useTranslation(); + const rate = calculateSwapRate({ sendAmount, destinationAmount }); + const rateValue = `1 ${srcCode} ≈ ${rate} ${dstCode}`; + return ( +
+
+ + {t("Rate")} +
+
+ {rateValue} +
+
+ ); +}; 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..babd436cbf --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineBanner.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@stellar/design-system"; + +interface TrustlineBannerProps { + tokenCode: string; + onClick: () => void; +} + +export const TrustlineBanner = ({ + tokenCode, + onClick, +}: TrustlineBannerProps) => { + const { t } = useTranslation(); + return ( + + ); +}; 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..bcd7bee15d --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/TrustlineInfoSheet.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Icon } from "@stellar/design-system"; + +import { InfoSheetContent } from "popup/components/InfoBottomSheet"; + +interface TrustlineInfoSheetProps { + 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 = ({ + 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__/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/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..ff52dba432 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/TrustlineInfoSheet.test.tsx @@ -0,0 +1,86 @@ +import React from "react"; +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(); + render( + + + , + ); + expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); + expect( + screen.getByText("This will add a trustline to USDC"), + ).toBeInTheDocument(); + }); + + it("renders the reserve explanation with the reserve amount emphasized", () => { + const onClose = jest.fn(); + render( + + + , + ); + // 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", () => { + 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/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()); +}; diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 7857618a0b..5bb08ff25d 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -27,7 +27,11 @@ 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 { + BlockaidWarning, + SecurityLevel, + mergeSecurityLevels, +} from "popup/constants/blockaid"; import { useBlockaidOverrideState, useShouldTreatTxAsUnableToScan, @@ -43,6 +47,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 +114,26 @@ interface ReviewTxProps { onConfirm: () => void; onCancel: () => void; onAddMemo?: () => void; + destinationTokenDetails?: { + tokenCode: string; + 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; + // Friendly per-feature reasons from the destination token scan, listed in + // the expandable Blockaid pane next to the transaction-scan reasons (§ batch4 + // task 3). + securityWarnings?: BlockaidWarning[]; + } | 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; + // Friendly per-feature reasons from the source token scan, listed in the + // expandable Blockaid pane alongside the transaction-scan reasons (§ batch4 + // task 3). + sourceTokenSecurityWarnings?: BlockaidWarning[]; } export const ReviewTx = ({ @@ -122,6 +149,9 @@ export const ReviewTx = ({ onConfirm, onCancel, onAddMemo, + destinationTokenDetails, + sourceTokenSecurityLevel, + sourceTokenSecurityWarnings, }: ReviewTxProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -153,6 +183,8 @@ export const ReviewTx = ({ const asset = getAssetFromCanonical(srcAsset); const dest = dstAsset ? getAssetFromCanonical(dstAsset.canonical) : null; + // A destination asset is only present on swaps; Send has a recipient address. + const isSwap = !!dstAsset; const assetIcons = srcAsset !== "native" ? { [srcAsset]: assetIcon } : {}; const truncatedDest = federationAddress ? truncatedFedAddress(federationAddress) @@ -168,18 +200,82 @@ 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, + sourceTokenSecurityLevel ?? null, + 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; + + 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; + + // 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; + + // Token-scan reasons (source + destination) shown in the expandable pane next + // to the transaction-scan reasons, so the user sees every flagged reason in + // one list, like mobile (§ batch4 task 3). + const tokenSecurityWarnings: BlockaidWarning[] = [ + ...(sourceTokenSecurityWarnings ?? []), + ...(destinationTokenDetails?.securityWarnings ?? []), + ]; /** * Pane state machine: @@ -204,12 +300,49 @@ 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; + })(); + + // When the token banner is shown (clean/absent tx scan) but the token carries + // friendly reasons, make the banner open the expandable pane so those reasons + // are reachable — mirroring the tx banner (§ batch4 task 3). + const tokenBannerOpensPane = + blockaidBannerKind === "token" && tokenSecurityWarnings.length > 0; + const isOnBlockaidPane = paneConfig.blockaidIndex !== null && activePaneIndex === paneConfig.blockaidIndex; 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( () => @@ -315,6 +448,10 @@ export const ReviewTx = ({ sendAmount={sendAmount} networkDetails={networkDetails} sendPriceUsd={sendPriceUsd} + isSuspicious={ + sourceTokenSecurityLevel === SecurityLevel.MALICIOUS || + sourceTokenSecurityLevel === SecurityLevel.SUSPICIOUS + } />
@@ -330,12 +467,19 @@ export const ReviewTx = ({ networkDetails={networkDetails} destination={destination} truncatedDest={truncatedDest} + isSuspicious={ + destTokenSecurityLevel === SecurityLevel.MALICIOUS || + destTokenSecurityLevel === SecurityLevel.SUSPICIOUS + } />
- {shouldShowTxWarning && 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" ? ( { @@ -344,16 +488,61 @@ export const ReviewTx = ({ } }} /> - )} + ) : blockaidBannerKind === "token" ? ( +
{ + if (paneConfig.blockaidIndex !== null) { + setActivePaneIndex(paneConfig.blockaidIndex); + } + }, + onKeyDown: (e: React.KeyboardEvent) => { + if ( + (e.key === "Enter" || e.key === " ") && + paneConfig.blockaidIndex !== null + ) { + e.preventDefault(); + setActivePaneIndex(paneConfig.blockaidIndex); + } + }, + } + : {})} + > + +
+ ) : null} {isRequiredMemoMissing && !isValidatingMemo && !shouldShowTxWarning && ( setActivePaneIndex(paneConfig.memoIndex)} /> )} + {requiresTrustline && ( + setIsOnTrustlinePane(true)} + /> + )}
- {/* Hide memo row when memo is disabled (e.g., for all M addresses) */} - {!isMemoDisabled && ( + {/* Swaps don't carry a memo; hide the row entirely. For Send, hide it + only when memo is disabled (e.g., for all M addresses). */} + {!isSwap && !isMemoDisabled && (
@@ -392,6 +581,14 @@ export const ReviewTx = ({ {fee} XLM
+ {dstAsset && dest && ( + + )}
@@ -407,10 +604,11 @@ export const ReviewTx = ({
); - const blockaidPane = ( { setActivePaneIndex(paneConfig.reviewIndex); }} @@ -457,13 +655,11 @@ export const ReviewTx = ({ /> ); - // Build panes in order (no hooks on JSX) - const panes: React.ReactNode[] = []; - if (shouldShowTxWarning) { - panes.push(reviewPane, memoPane, blockaidPane, feesPane); - } else { - panes.push(reviewPane, memoPane, feesPane); - } + // Build panes in order (no hooks on JSX). The trustline info is a slide-up + // sheet overlay (rendered below), not a pane. + const panes: React.ReactNode[] = shouldShowTxWarning + ? [reviewPane, memoPane, blockaidPane, feesPane] + : [reviewPane, memoPane, feesPane]; return ( @@ -475,8 +671,19 @@ export const ReviewTx = ({ /> ) : (
- - {!isOnFeesPane && ( + {/* 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)} + /> + ) : ( + + )} + {!isOnFeesPane && !isOnTrustlinePane && (
)} diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss index 06349fed1d..102eec67b7 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss @@ -13,6 +13,12 @@ &__Warnings { margin-top: pxToRem(32px); + + // The token banner is clickable when it has expandable token-scan reasons + // to reveal (§ batch4 task 3); show the affordance. + &__token--clickable { + cursor: pointer; + } } &__loader { @@ -58,6 +64,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; @@ -290,4 +314,34 @@ color: var(--sds-clr-gray-12); } } + + &__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; + + &__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; + } + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + flex-shrink: 0; + } + } } 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/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/WarningMessages/index.tsx b/extension/src/popup/components/WarningMessages/index.tsx index 61f0f2fda8..1caa2c364d 100644 --- a/extension/src/popup/components/WarningMessages/index.tsx +++ b/extension/src/popup/components/WarningMessages/index.tsx @@ -28,7 +28,7 @@ import { useShouldTreatAssetAsUnableToScan, useShouldTreatTxAsUnableToScan, } from "popup/helpers/blockaid"; -import { SecurityLevel } from "popup/constants/blockaid"; +import { BlockaidWarning, SecurityLevel } from "popup/constants/blockaid"; import "./styles.scss"; @@ -538,8 +538,8 @@ export const BlockaidAssetWarning = ({ } const header = isMaliciousFinal - ? t("This asset was flagged as malicious") - : t("This asset was flagged as suspicious"); + ? t("This token was flagged as malicious") + : t("This token was flagged as suspicious"); const scanType = isMaliciousFinal ? "ScanMalicious" : "ScanMiss"; return ( @@ -615,13 +615,13 @@ export const BlockAidAssetScanExpanded = ({ headerIcon={} title={t("Do not proceed")} subtitle={t( - "This asset has been flagged as malicious for the following reasons.", + "This token has been flagged as malicious for the following reasons.", )} detailRows={
- {t("This asset was flagged as malicious (override active)")} + {t("This token was flagged as malicious (override active)")}
} @@ -637,13 +637,13 @@ export const BlockAidAssetScanExpanded = ({ headerIcon={} title={t("Suspicious Request")} subtitle={t( - "This asset has been flagged as suspicious for the following reasons.", + "This token has been flagged as suspicious for the following reasons.", )} detailRows={
- {t("This asset was flagged as suspicious (override active)")} + {t("This token was flagged as suspicious (override active)")}
} @@ -684,13 +684,13 @@ export const BlockAidAssetScanExpanded = ({ ? { title: t("Do not proceed"), description: t( - "This asset has been flagged as malicious for the following reasons.", + "This token has been flagged as malicious for the following reasons.", ), } : { title: t("Suspicious Request"), description: t( - "This asset has been flagged as suspicious for the following reasons.", + "This token has been flagged as suspicious for the following reasons.", ), }; @@ -699,8 +699,8 @@ export const BlockAidAssetScanExpanded = ({ : { class: "WarningMark", icon: }; const fallbackMessage = isMalicious - ? t("This asset was flagged as malicious") - : t("This asset was flagged as suspicious"); + ? t("This token was flagged as malicious") + : t("This token was flagged as suspicious"); const featureRows = features.length > 0 ? ( @@ -1000,6 +1000,16 @@ interface BlockAidScanExpandedProps { scanResult: BlockAidScanTxResult | BlockAidScanAssetResult | null | undefined; onClose?: () => void; isAssetScan?: boolean; + // Additional friendly reasons to list alongside the scan's own (e.g. on a + // swap, the source/destination token-scan features shown together with the + // transaction-scan reasons, mirroring mobile — § batch4 task 3). + extraWarnings?: BlockaidWarning[]; + // The verdict that drives the parent gate's malicious/suspicious styling for + // those extra reasons (e.g. a swap token's merged SecurityLevel). Folded into + // the pane's title/icon so the pane can never under-state severity relative + // to the gate when a token is flagged via result_type but carries no + // matching feature row (§ batch4 task 3). + extraSeverityLevel?: SecurityLevel | null; } interface WarningInfo { @@ -1069,7 +1079,7 @@ const getScanWarnings = ( warnings.push({ icon: , text: isAssetScan - ? t("This asset was flagged as malicious (override active)") + ? t("This token was flagged as malicious (override active)") : t("This transaction was flagged as malicious (override active)"), isError: true, }); @@ -1080,7 +1090,7 @@ const getScanWarnings = ( warnings.push({ icon: , text: isAssetScan - ? t("This asset was flagged as suspicious (override active)") + ? t("This token was flagged as suspicious (override active)") : t("This transaction was flagged as suspicious (override active)"), isError: false, }); @@ -1092,7 +1102,7 @@ const getScanWarnings = ( warnings.push({ icon: , text: isAssetScan - ? t("Unable to scan asset") + ? t("Unable to scan token") : t("Unable to scan transaction"), isError: false, }); @@ -1123,7 +1133,30 @@ const getScanWarnings = ( } if (validation && "result_type" in validation) { - if (validation.description) { + // Prefer the per-feature friendly descriptions (same as the asset/ + // add-token path) over the raw top-level validation.description, which is + // a developer string like "Token issuer
is + // flagged as malicious" (§ batch3 task 3). Fall back to the top-level + // description only when there are no flagged features. + const validationFeatures = + ("features" in validation && validation.features) || []; + const flaggedFeatures = validationFeatures.filter( + (feature) => feature.type === "Warning" || feature.type === "Malicious", + ); + if (flaggedFeatures.length > 0) { + flaggedFeatures.forEach((feature) => { + warnings.push({ + icon: + feature.type === "Malicious" ? ( + + ) : ( + + ), + text: feature.description, + isError: feature.type === "Malicious", + }); + }); + } else if (validation.description) { warnings.push({ icon: isMalicious ? : , text: validation.description, @@ -1153,6 +1186,8 @@ export const BlockAidScanExpanded = ({ scanResult, onClose, isAssetScan: isAssetScanProp, + extraWarnings, + extraSeverityLevel, }: BlockAidScanExpandedProps) => { const { t } = useTranslation(); const shouldTreatTxAsUnableToScan = useShouldTreatTxAsUnableToScan(); @@ -1173,7 +1208,12 @@ export const BlockAidScanExpanded = ({ blockaidOverrideState === SecurityLevel.MALICIOUS || blockaidOverrideState === SecurityLevel.SUSPICIOUS; - if (!scanResult && !isUnableToScan && !hasActiveOverride) { + if ( + !scanResult && + !isUnableToScan && + !hasActiveOverride && + !extraWarnings?.length + ) { return null; } @@ -1186,25 +1226,64 @@ export const BlockAidScanExpanded = ({ blockaidOverrideState, isAssetScan, ); + + // Append any caller-supplied reasons (e.g. swap token-scan features), so the + // pane lists the transaction-scan and token-scan reasons together like mobile + // (§ batch4 task 3). Dedupe against the scan's own rows by text, and among the + // extras by featureId, so the same reason never doubles up. + const extraRows = (extraWarnings ?? []) + .filter( + (extra) => + !warnings.some((existing) => existing.text === extra.description), + ) + .filter((extra, index, arr) => { + const key = extra.featureId || extra.description; + return ( + arr.findIndex( + (other) => (other.featureId || other.description) === key, + ) === index + ); + }) + .map((extra) => ({ + icon: extra.isError ? : , + text: extra.description, + isError: extra.isError, + })); + + const allWarnings = [...warnings, ...extraRows]; + // A malicious extra escalates the whole pane to "Do not proceed"; any extra + // at least makes it suspicious. extraSeverityLevel covers the case where the + // caller's verdict is Malicious/Suspicious (via result_type) but carries no + // matching feature row, so the pane title stays in lockstep with the gate. + const hasMaliciousExtra = + extraRows.some((row) => row.isError) || + extraSeverityLevel === SecurityLevel.MALICIOUS; + const mergedIsMalicious = isMalicious || hasMaliciousExtra; + const mergedIsSuspicious = + !mergedIsMalicious && + (isSuspicious || + extraRows.length > 0 || + extraSeverityLevel === SecurityLevel.SUSPICIOUS); + let requestId = ""; if (scanResult && "request_id" in scanResult && scanResult.request_id) { requestId = scanResult.request_id; } // Early return if no warnings - if (warnings.length === 0) { + if (allWarnings.length === 0) { return null; } - const title = isMalicious + const title = mergedIsMalicious ? t("Do not proceed") - : isSuspicious + : mergedIsSuspicious ? t("Suspicious Request") : t("Proceed with caution"); const subtitle = isAssetScan - ? t("This asset does not appear safe for the following reasons.") + ? t("This token does not appear safe for the following reasons.") : t("This transaction does not appear safe for the following reasons."); - const headerIcon = isMalicious ? ( + const headerIcon = mergedIsMalicious ? (
@@ -1225,7 +1304,7 @@ export const BlockAidScanExpanded = ({
{title}
{subtitle}
- {warnings.map((warning, index) => ( + {allWarnings.map((warning, index) => (
{ }); }); + 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 diff --git a/extension/src/popup/components/account/AccountAssets/index.tsx b/extension/src/popup/components/account/AccountAssets/index.tsx index 96ebd00cce..531c30f7f6 100644 --- a/extension/src/popup/components/account/AccountAssets/index.tsx +++ b/extension/src/popup/components/account/AccountAssets/index.tsx @@ -29,9 +29,9 @@ import { transactionSubmissionSelector } from "popup/ducks/transactionSubmission import { ScamAssetIcon } from "popup/components/account/ScamAssetIcon"; import ImageMissingIcon from "popup/assets/image-missing.svg?react"; import IconSoroban from "popup/assets/icon-soroban.svg?react"; -import { getPriceDeltaColor } from "popup/helpers/balance"; import { AccountHistoryData } from "popup/views/Account/hooks/useGetAccountHistoryData"; import { ROUTES } from "popup/constants/routes"; +import { BalanceRow } from "popup/components/BalanceRow"; import "./styles.scss"; import { AssetDetail } from "../AssetDetail"; @@ -334,80 +334,32 @@ export const AccountAssets = ({ }} key={canonicalAsset} > -
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} 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 { 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..c5bb7be169 --- /dev/null +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -0,0 +1,195 @@ +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 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( + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", + ), + ).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("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( + + + , + ); + 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("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 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( + + + , + ); + // 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 new file mode 100644 index 0000000000..84c245a6a0 --- /dev/null +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -0,0 +1,310 @@ +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; + /** 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 + * 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; + onToggleInputType: () => void; + onSelectAsset: () => void; +} + +export const AmountCard = ({ + label, + availableBalanceText, + availableBalanceFontSizePx, + inputType, + amount, + amountUsd, + amountFontSizeClass, + assetCode, + assetIcon, + assetIcons, + assetIssuerKey, + securityLevel, + supportsUsd, + fiatLineText, + isAmountTooHigh, + maxSpendableText = "", + isReadOnly = false, + autoFocus = true, + amountInputRef, + onInputFocus, + onInputBlur, + 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 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); + + // 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, amountFontSizeClass]); + + useLayoutEffect(() => { + if (fiatSpanRef.current) { + setInputWidthFiat(fiatSpanRef.current.offsetWidth + 4); + } + }, [amountUsd, amountFontSizeClass]); + + const isSuspicious = + securityLevel === SecurityLevel.MALICIOUS || + securityLevel === SecurityLevel.SUSPICIOUS; + + const fontClass = `AmountCard__input-amount AmountCard__${amountFontSizeClass}`; + + return ( +
+
+ {label} + + {availableBalanceText} + +
+ +
+ {/* Focus the input when anywhere between the amount and the asset + picker is clicked, not just the (content-width) input itself. */} +
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" && ( + { + 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; + }); + }} + onFocus={onInputFocus} + onBlur={onInputBlur} + autoFocus={autoFocus} + autoComplete="off" + /> + )} + {inputType === "fiat" && ( + <> +
+ $ +
+ { + 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; + }); + }} + onFocus={onInputFocus} + onBlur={onInputBlur} + autoFocus={autoFocus} + autoComplete="off" + /> + + )} +
+ +
+ + {/* 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 && ( +
+ + + {t( + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", + { + amount: maxSpendableText, + symbol: 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..3671cac474 --- /dev/null +++ b/extension/src/popup/components/amount/AmountCard/styles.scss @@ -0,0 +1,211 @@ +@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); + } + } + + // Empty "+ Select" state (e.g. the swap receive card before a token is + // picked): a circular badge the same footprint (20px + 4px margin) as the + // asset logo, with a small plus on a slightly lighter background. + &__select-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: pxToRem(20px); + height: pxToRem(20px); + margin: pxToRem(4px); + border-radius: 50%; + background-color: var(--sds-clr-gray-04); + // Muted plus, matching the "You receive" label color. + color: var(--sds-clr-gray-11); + + svg { + 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; + + // Keep the fiat/crypto toggle's background transparent in every state. + // The SDS tertiary variant otherwise paints gray-01/gray-04 + a focus + // glow on hover/active/focus; the icon stroke comes from + // --Button-color-icon-* (independent of background) so it stays visible + // (§ batch4 task 6). + &, + &:hover, + &:active, + &:focus, + &:focus-visible { + --Button-color-background-default: transparent; + --Button-color-background-hover: transparent; + --Button-color-background-active: transparent; + background-color: transparent !important; + box-shadow: none !important; + } + } + } + + &__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); + } + } +} 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); + } + } +} diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx index 444977dcb0..78731fe356 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"; @@ -35,6 +31,8 @@ export type ManageAssetCurrency = { balance?: string; name?: string; image?: string | null; + /** USD spot price from the stellar.expert search response, if present. */ + price?: number; }; export interface NewAssetFlags { @@ -116,34 +114,37 @@ export const ManageAssetRows = ({ isTrustlineActive, name, }) => ( - <> - - { - setSelectedAsset({ - code, - issuer, - domain, - name, - image, - isTrustlineActive, - contract, - }); - }} - /> - + { + setSelectedAsset({ + code, + issuer, + domain, + name, + image, + isTrustlineActive, + contract, + }); + }} + /> + } + /> )} />
@@ -436,54 +437,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 || "")} -
-
- - ); -}; 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 ea5c0c92dd..70dcff0126 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,73 @@ 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]) => ( - - ))} - -
+
)} diff --git a/extension/src/popup/components/send/styles.scss b/extension/src/popup/components/send/styles.scss index db9cc9e4f1..65b11e28e7 100644 --- a/extension/src/popup/components/send/styles.scss +++ b/extension/src/popup/components/send/styles.scss @@ -152,6 +152,11 @@ align-items: center; gap: pxToRem(4px); font-size: pxToRem(14px); + + // Match the Swap flow: the "Fee:" label is muted gray, not primary text. + &__label { + color: var(--sds-clr-gray-11); + } } &__amount-price { 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..4889a81913 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx @@ -0,0 +1,331 @@ +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + 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 XlmReserve from "popup/helpers/xlmReserve"; + +// 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"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const usdcBalance = { + token: { code: "USDC", issuer: { key: USDC_ISSUER } }, + 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("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).toHaveTextContent("Enter an amount"); + + // 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); + + expect(sellInput).toHaveFocus(); + 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") + .mockReturnValue(false); + // Hold USDC but no XLM, so the network fee can't be paid. + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { ...swapData, userBalances: { balances: [usdcBalance] } }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + render( + + + , + ); + const btn = screen.getByTestId("swap-amount-btn-continue"); + expect(btn).toBeDisabled(); + expect(btn).toHaveTextContent("Not enough XLM for network fees"); + }); + + 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/__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/__tests__/SwapAmount.fiatLabel.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx new file mode 100644 index 0000000000..3f3a790b96 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx @@ -0,0 +1,158 @@ +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", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }, + 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(); + }); + + it("falls back to the destination's stellar.expert spot price when /token-prices has none", () => { + // tokenPrices is empty -> the receive card relies on the spot-price fallback. + mockAmountData({}); + renderAmount({ + asset: "native", + destinationAsset: USDC_CANONICAL, + destinationAmount: "10", + destinationTokenDetails: { + tokenCode: "USDC", + issuer: USDC_ISSUER, + requiresTrustline: true, + decimals: 7, + spotPrice: 0.5, + }, + }); + + // 10 USDC * $0.5 spot = $5 -> receive card shows a value, not "--". + const receive = screen.getByTestId("swap-receive-card"); + expect(within(receive).getByText(/^\$5/)).toBeInTheDocument(); + expect(within(receive).queryByText("--")).toBeNull(); + }); +}); 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..8f0b31363a --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx @@ -0,0 +1,80 @@ +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(); + }); + + 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/__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/__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/__tests__/SwapAmount.telemetry.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx new file mode 100644 index 0000000000..72cdfa4905 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx @@ -0,0 +1,210 @@ +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 { 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"; +import * as XlmReserve from "popup/helpers/xlmReserve"; + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("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" }, + 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(); + toastCustomMock.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({}); + + // The quote-expired toast fires (sonner toast.custom) instead of a fixed + // banner taking layout space. + await waitFor(() => { + expect(toastCustomMock).toHaveBeenCalled(); + }); + + 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(toastCustomMock).not.toHaveBeenCalled(); + expect( + emitMetricMock.mock.calls.find((c) => c[0] === "swap: quote expired"), + ).toBeUndefined(); + }); + + it("does NOT emit swapTrustlineAdded at review time — it fires post-confirmation", 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); + }); + + // 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/helpers/__tests__/swapCtaState.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts new file mode 100644 index 0000000000..66a533d205 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts @@ -0,0 +1,92 @@ +import { getSwapCtaState, SwapCtaInputs } from "../swapCtaState"; + +const base: SwapCtaInputs = { + hasSource: true, + hasDestination: true, + availableBalanceIsZero: false, + amountIsZero: false, + isAmountTooHigh: false, + insufficientXlmForFees: false, + hasNoSwapPath: false, +}; + +describe("getSwapCtaState", () => { + 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: false, + labelKey: "select", + }); + expect(getSwapCtaState({ ...base, hasDestination: false })).toEqual({ + disabled: false, + labelKey: "select", + }); + }); + + 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: 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, + labelKey: "insufficientBalance", + }); + }); + + it("flags insufficient XLM for fees (non-XLM source)", () => { + expect(getSwapCtaState({ ...base, insufficientXlmForFees: true })).toEqual({ + disabled: true, + labelKey: "insufficientXlmFees", + }); + }); + + it("flags no quote available when the path is empty", () => { + expect(getSwapCtaState({ ...base, hasNoSwapPath: true })).toEqual({ + disabled: true, + labelKey: "noQuote", + }); + }); + + it("enables Review swap when everything checks out", () => { + expect(getSwapCtaState(base)).toEqual({ + disabled: false, + labelKey: "review", + }); + }); + + it("prefers the source-balance error over the fee error", () => { + expect( + getSwapCtaState({ + ...base, + isAmountTooHigh: true, + insufficientXlmForFees: true, + }).labelKey, + ).toBe("insufficientBalance"); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts new file mode 100644 index 0000000000..b43617c924 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts @@ -0,0 +1,58 @@ +// Pure CTA state machine for the swap amount screen, mirroring mobile's +// useSwapCtaState. Precedence matters: each guard short-circuits, so the +// label reflects the most specific blocker (§2.4/§2.5). + +export type SwapCtaLabelKey = + | "select" + | "enter" + | "insufficientBalance" + | "insufficientXlmFees" + | "noQuote" + | "review"; + +export interface SwapCtaInputs { + hasSource: boolean; + hasDestination: boolean; + availableBalanceIsZero: boolean; + amountIsZero: boolean; + isAmountTooHigh: boolean; + insufficientXlmForFees: boolean; + hasNoSwapPath: boolean; +} + +export const getSwapCtaState = ({ + hasSource, + hasDestination, + availableBalanceIsZero, + amountIsZero, + isAmountTooHigh, + 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: 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: false, labelKey: "enter" }; + } + if (isAmountTooHigh) { + return { disabled: true, labelKey: "insufficientBalance" }; + } + if (insufficientXlmForFees) { + return { disabled: true, labelKey: "insufficientXlmFees" }; + } + if (hasNoSwapPath) { + return { disabled: true, labelKey: "noQuote" }; + } + return { disabled: false, labelKey: "review" }; +}; 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..1e90042a09 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,139 @@ -import { getSwapErrorMessage, ERROR_TO_DISPLAY } from "../useSimulateSwapData"; +import { Asset } from "stellar-sdk"; + +import { + getBuiltTx, + getPerOpBaseFee, + getSwapTotalFee, + 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("getSwapTotalFee", () => { + it("scales the recommended default fee by op count (2 ops for a new trustline)", () => { + expect(getSwapTotalFee({ recommendedFee: "0.001", opCount: 2 })).toBe( + "0.002", + ); + }); + + it("leaves the recommended fee as-is for a single op", () => { + expect(getSwapTotalFee({ recommendedFee: "0.001", opCount: 1 })).toBe( + "0.001", + ); + }); + + it("honors a custom fee as the total regardless of op count", () => { + expect( + getSwapTotalFee({ + recommendedFee: "0.001", + customFee: "0.005", + opCount: 2, + }), + ).toBe("0.005"); + }); + + it("passes through an empty recommended fee without producing NaN", () => { + expect(getSwapTotalFee({ recommendedFee: "", opCount: 2 })).toBe(""); + }); +}); + +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/useGetSwapAmountData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx index 1c6f68599d..53e55c5c9e 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx @@ -36,6 +36,8 @@ function useGetSwapAmountData( includeIcons: boolean; }, 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, @@ -84,8 +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 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/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 9d4aea6f53..9bc999e7ec 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 { @@ -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 { @@ -26,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"; @@ -79,8 +82,49 @@ export interface SimulateTxData { transactionXdr: string; dstAmountPriceUsd: string; scanResult?: BlockAidScanTxResult | null; + destMin?: string; } +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(); +}; + +// Total swap fee shown/charged. A new-trustline swap is two ops (changeTrust +// + pathPaymentStrictSend), so the recommended default scales with op count — +// each op pays the recommended fee. A user-set custom fee is treated as the +// total and split per op at build time (getPerOpBaseFee). Mirrors mobile's +// recommendedFee × ops default (§3.6/§3.7). +export const getSwapTotalFee = ({ + recommendedFee, + customFee, + opCount, +}: { + recommendedFee: string; + customFee?: string; + opCount: number; +}): string => { + if (customFee) { + return customFee; + } + if (!recommendedFee) { + return recommendedFee; + } + return new BigNumber(recommendedFee).times(opCount).toFixed(); +}; + const getOperation = ( sourceAsset: Asset | { code: string; issuer: string }, destAsset: Asset | { code: string; issuer: string }, @@ -104,7 +148,7 @@ const getOperation = ( }); }; -const getBuiltTx = async ( +export const getBuiltTx = async ( publicKey: string, opData: { sourceAsset: Asset | { code: string; issuer: string }; @@ -113,6 +157,7 @@ const getBuiltTx = async ( allowedSlippage: string; destinationAmount: string; path: string[]; + destinationTokenDetails: DestinationTokenDetails; }, fee: string, transactionTimeout: number, @@ -126,6 +171,7 @@ const getBuiltTx = async ( allowedSlippage, destinationAmount, path, + destinationTokenDetails, } = opData; const server = stellarSdkServer( networkDetails.networkUrl, @@ -133,6 +179,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 +213,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 +231,9 @@ function useSimulateTxData({ networkDetails: NetworkDetails; simParams: SimulationParams; }) { - const { memo } = useSelector(transactionDataSelector); + const { memo, destinationTokenDetails } = useSelector( + transactionDataSelector, + ); const reduxDispatch = useDispatch(); const { scanTx } = useScanTx(); @@ -174,6 +241,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, @@ -183,6 +254,7 @@ function useSimulateTxData({ destinationRate?: string; }) => { dispatch({ type: "FETCH_DATA_START" }); + setIsQuoteExpired(false); try { const payload = { transactionXdr: "" } as SimulateTxData; const { allowedSlippage, sourceAsset, destAsset, transactionTimeout } = @@ -234,6 +306,7 @@ function useSimulateTxData({ destinationAmount, allowedSlippage, path, + destinationTokenDetails, }, baseFee.toString(), transactionTimeout, @@ -261,6 +334,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; } @@ -269,6 +343,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 61ece368b4..46560b005c 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -1,10 +1,14 @@ -import React, { useEffect, useRef, 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 { toast } from "sonner"; import BigNumber from "bignumber.js"; +import { captureException } from "@sentry/browser"; import { object as YupObject, number as YupNumber } from "yup"; +import { BASE_RESERVE } from "@shared/constants/stellar"; import { Button, Card, @@ -16,33 +20,49 @@ 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, + saveDestinationTokenDetails, + saveIsToken, + saveSwapBestPath, saveTransactionFee, saveTransactionTimeout, + clearSwapQuoteExpired, transactionDataSelector, transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; import { cleanAmount, formatAmount, - formatAmountPreserveCursor, roundUsdValue, } 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 { + findAssetBalance, + getBalanceCanonicalKey, +} from "popup/helpers/balance"; +import { + getAssetSecurityLevel, + extractAssetScanWarnings, + useBlockaidOverrideState, +} from "popup/helpers/blockaid"; import { AppDispatch } from "popup/App"; import { emitMetric } from "helpers/metrics"; import { AMOUNT_ERROR, InputType } from "helpers/transaction"; @@ -50,16 +70,41 @@ import { METRIC_NAMES } from "popup/constants/metricsNames"; import { LoadingBackground } from "popup/basics/LoadingBackground"; import { EditSettings } from "popup/components/InternalTransaction/EditSettings"; import { ReviewTx } from "popup/components/InternalTransaction/ReviewTransaction"; -import { useSimulateTxData } from "./hooks/useSimulateSwapData"; +import { + getSwapTotalFee, + useSimulateTxData, +} from "./hooks/useSimulateSwapData"; +import { getSwapCtaState, SwapCtaLabelKey } from "./helpers/swapCtaState"; 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 { + deductNewTrustlineReserve, + pickBestNonXlmClassicCanonical, + shouldShowXlmReservePreflight, +} from "popup/helpers/xlmReserve"; +import { horizonGetBestReceivePath } from "popup/helpers/horizonGetBestPath"; +import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; +import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import "./styles.scss"; -const defaultSlippage = "1"; -const DEFAULT_INPUT_WIDTH = 25; +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 }, + { 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; @@ -80,11 +125,17 @@ export const SwapAmount = ({ }: SwapAmountProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); - const { networkCongestion, recommendedFee } = useNetworkFees(); - const runAfterUpdate = useRunAfterUpdate(); + const { + networkCongestion, + recommendedFee, + isLoading: isNetworkFeesLoading, + } = useNetworkFees(); const networkDetails = useSelector(settingsNetworkDetailsSelector); const publicKey = useSelector(publicKeySelector); - const { transactionData } = useSelector(transactionSubmissionSelector); + const blockaidOverrideState = useBlockaidOverrideState(); + const { transactionData, isSwapQuoteExpired } = useSelector( + transactionSubmissionSelector, + ); const { allowedSlippage, amount, @@ -99,8 +150,20 @@ export const SwapAmount = ({ transactionFee, transactionTimeout, } = transactionData; - const fee = transactionFee || recommendedFee; - const srcAsset = getAssetFromCanonical(asset); + // A new-trustline swap is two ops; scale the recommended default fee by op + // count so each op pays the recommended fee (a custom fee is the total and + // is split per op at build time). Mirrors mobile (§3.6/§3.7). + const swapOpCount = transactionData.destinationTokenDetails?.requiresTrustline + ? 2 + : 1; + const fee = getSwapTotalFee({ + recommendedFee, + customFee: transactionFee, + opCount: swapOpCount, + }); + // 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; @@ -111,58 +174,75 @@ export const SwapAmount = ({ includeIcons: true, }, destination, + destinationAsset, + asset, ); - const { state: simulationState, fetchData: fetchSimulationData } = - useSimulateTxData({ - publicKey, - networkDetails, - simParams: { - sourceAsset: srcAsset, - destAsset: dstAsset!, - amount, - allowedSlippage, - path, - transactionFee: fee, - transactionTimeout, - 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 { + 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); + // 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 }) => { - const amount = inputType === "crypto" ? values.amount : priceValue!; - const cleanedAmount = cleanAmount(amount); + // Retrying after a quote-expiry submit failure: dismiss the stale notice + // before re-simulating against a fresh quote (§2.1/§3.3). + if (isSwapQuoteExpired) { + dispatch(clearSwapQuoteExpired()); + } + const amountVal = + inputType === "crypto" ? values.amount : (priceValue ?? "0"); + 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); }; 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 }; @@ -181,34 +261,29 @@ export const SwapAmount = ({ validateOnChange: true, }); - const getAmountFontSize = () => { - const length = formik.values.amount.length; - if (length <= 9) { - return ""; + // 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"; } - 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 = + isNetworkFeesLoading || 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(); @@ -217,6 +292,198 @@ 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, + 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 toast. The auto-refetch is handled by Phase E's getBestPath + // retry; this only emits + surfaces the message. + useEffect(() => { + if (!isQuoteExpired) { + return; + } + showQuoteExpiredToast(); + emitMetric(METRIC_NAMES.swapQuoteExpired, { + sourceToken: asset, + destToken: destinationAsset, + sourceAmount: amount, + destAmount: destinationAmount, + allowedSlippage, + }); + // 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) — + // 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 }; + 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 + } + // This is the current request settling — stop signalling "loading" + // so the CTA can distinguish a missing path from a pending one. + setIsLiveQuoteLoading(false); + 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; + } + setIsLiveQuoteLoading(false); + // No path / network error: clear the stale received amount. + dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); + } + })(); + }, 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 + [], + ); + + 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)) { + setIsLiveQuoteLoading(true); + 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). + setIsLiveQuoteLoading(false); + debouncedQuote.cancel(); + liveQuoteReqRef.current += 1; + if ( + destinationAmountRef.current !== "0" && + destinationAmountRef.current !== "" + ) { + dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); + } + } + // 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, + asset, + destinationAsset, + inputType, + swapAmountData.state, + ]); + if (isLoading) { return ; } @@ -258,14 +525,34 @@ 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; + // 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; + // 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; - const dstAssetPrice = prices[destinationAsset]?.currentPrice; + // Prefer the live backend price; fall back to the stellar.expert spot price + // captured when the (non-held) destination token was picked, so the receive + // card shows a fiat value instead of "--" when /token-prices has no entry. + const dstSpotPrice = transactionData.destinationTokenDetails?.spotPrice; + const dstAssetPrice = + prices[destinationAsset]?.currentPrice ?? + // Spot-price fallback is mainnet-only, mirroring the /token-prices gate so + // the receive card never shows a fiat value the sell card can't. + (isMainnet(data.networkDetails) && dstSpotPrice != null + ? String(dstSpotPrice) + : undefined); const assetDecimals = getAssetDecimals(asset, sendData.userBalances, isToken); const priceValue = assetPrice ? new BigNumber(cleanAmount(formik.values.amountUsd)) @@ -282,38 +569,213 @@ export const SwapAmount = ({ ), )}` : null; - const recommendedFeeUsd = xlmPrice - ? `$${formatAmount( + const supportsUsd = isMainnet(data.networkDetails) && assetPrice; + const dstPriceValueUsd = dstAssetPrice + ? formatAmount( roundUsdValue( - new BigNumber(xlmPrice).multipliedBy(new BigNumber(fee)).toString(), + new BigNumber(dstAssetPrice) + .multipliedBy(new BigNumber(cleanAmount(destinationAmount || "0"))) + .toString(), ), - )}` + ) : null; - const supportsUsd = isMainnet(data.networkDetails) && assetPrice; - const availableBalance = getAvailableBalance({ - assetCanonical: asset, - balances: sendData.userBalances.balances, - recommendedFee: fee, + const baseAvailableBalance = asset + ? getAvailableBalance({ + assetCanonical: asset, + balances: sendData.userBalances.balances, + recommendedFee: fee, + }) + : "0"; + // When swapping XLM into a new token, reserve the 0.5 XLM trustline bump + // up-front so it's excluded from Max / percentage buttons and the + // insufficient-balance check (matches mobile; §2.2). + const availableBalance = deductNewTrustlineReserve({ + spendable: baseAvailableBalance, + sourceIsXlm: asset === "native", + requiresTrustline: + transactionData.destinationTokenDetails?.requiresTrustline ?? false, }); const displayTotal = `${formatAmount(availableBalance)}`; - const dstDisplayTotal = - dstAssetBalance && dstAsset - ? `${formatAmount(dstAssetBalance.total.toString())}` - : "0"; + + // "Swap for 0.5 XLM" reserve-recovery affordance on the XlmReserveSheet + // (§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; + // Friendly per-feature reasons from the source token scan, surfaced in the + // review's Blockaid pane alongside the transaction-scan reasons (§ batch4 + // task 3). + const sourceTokenSecurityWarnings = + sourceBalance && "blockaidData" in sourceBalance + ? extractAssetScanWarnings(sourceBalance.blockaidData) + : 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( + sendData.userBalances.balances, + ); + const canSwapForReserve = + sourceIsNonXlmClassic || !!bestNonXlmClassicCanonical; + + const handleSwapForReserve = async () => { + const sellCanonical = sourceIsNonXlmClassic + ? asset + : bestNonXlmClassicCanonical; + if (!sellCanonical) { + return; + } + // The receive side becomes XLM — a held token, so no trustline is needed. + dispatch(saveDestinationAsset("native")); + dispatch(saveDestinationTokenDetails(null)); + + if (!sourceIsNonXlmClassic) { + // Switching the sell side to a different token: reset the amount, since + // any prior amount was denominated in the now-replaced source. + dispatch(saveAsset(sellCanonical)); + dispatch(saveAmount("0")); + dispatch(saveAmountUsd("0.00")); + return; + } + + // Source reused (no token change): pre-fill the amount needed to receive + // ~0.5 XLM, capped to what's spendable of the sell token so the user never + // lands on an insufficient-balance state. + try { + // Target a little MORE than the bare reserve so the 0.5 XLM trustline + // bump stays covered after the swap's slippage floor (destMin). Sizing to + // BASE_RESERVE / (1 - slippage) keeps even a worst-case fill at >= 0.5, + // erring slightly over rather than under (§ batch4 task 5). Still capped + // to spendable below, so it never exceeds the user's balance. + const slippageFraction = Math.min( + Math.max(parseFloat(allowedSlippage) || 0, 0) / 100, + 0.5, + ); + const reserveTarget = new BigNumber(BASE_RESERVE) + .dividedBy(1 - slippageFraction) + .toFixed(7); + const path = await horizonGetBestReceivePath({ + destinationAmount: reserveTarget, + sourceAsset: sellCanonical, + destAsset: "native", + networkDetails, + }); + if (path?.source_amount) { + const sellSpendable = getAvailableBalance({ + assetCanonical: sellCanonical, + balances: sendData.userBalances.balances, + recommendedFee: fee, + }); + const capped = BigNumber.minimum( + new BigNumber(path.source_amount), + 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. + captureException( + `Swap-for-reserve prefill failed - ${JSON.stringify(e)}`, + ); + } + }; + const isAmountTooHigh = (inputType === "crypto" && new BigNumber(cleanAmount(formik.values.amount)).gt( new BigNumber(availableBalance), )) || (inputType === "fiat" && - new BigNumber(cleanAmount(priceValue!)).gt( + new BigNumber(cleanAmount(priceValue ?? "0")).gt( new BigNumber(availableBalance), )); - const goToEditSrcAction = () => { - goToEditSrc(); + const swapAmountPositive = + inputType === "crypto" + ? new BigNumber(cleanAmount(formik.values.amount)).gt(0) + : new BigNumber(cleanAmount(formik.values.amountUsd)).gt(0); + + // The live quote settled with no route for a positive amount → no swap path. + // While a quote is in flight (isLiveQuoteLoading) we leave the CTA enabled so + // it doesn't flicker disabled between keystrokes (§2.5). + const hasNoSwapPath = + swapAmountPositive && + !isLiveQuoteLoading && + new BigNumber(cleanAmount(destinationAmount || "0")).isZero(); + + // Non-XLM swaps pay the network fee from the separate XLM balance; block the + // CTA when that balance can't cover the fee (§2.4). XLM-source swaps already + // fold the fee into availableBalance, so this only applies to non-XLM sources. + const xlmSpendableForFees = getAvailableBalance({ + assetCanonical: "native", + balances: sendData.userBalances.balances, + recommendedFee: "0", + }); + const insufficientXlmForFees = + sourceIsNonXlmClassic && + new BigNumber(xlmSpendableForFees).lt(new BigNumber(fee)); + + 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, + hasNoSwapPath, + }); + const ctaLabels: Record = { + select: t("Select a token"), + enter: t("Enter an amount"), + insufficientBalance: t("Insufficient balance"), + insufficientXlmFees: t("Not enough XLM for network fees"), + noQuote: t("No quote available"), + review: t("Review swap"), }; + const availableBalanceText = srcAsset + ? `${displayTotal} ${srcAsset.code} ${t("available")}` + : ""; + const availableBalanceFontSizePx = AVAILABLE_BALANCE_FONT_SIZES.find( + ({ maxLen }) => availableBalanceText.length <= maxLen, + )!.sizePx; + return ( <> {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`}
} @@ -384,215 +863,218 @@ 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} -
- +
+ -
- $ -
- - {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, - })} - - - )} + // 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} + amountInputRef={sellInputRef} + onInputFocus={() => setIsSellInputFocused(true)} + onInputBlur={() => setIsSellInputFocused(false)} + assetCode={srcAsset ? srcAsset.code : ""} + assetIcon={assetIcon} + assetIcons={ + 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 + ? "$0.00" + : inputType === "crypto" + ? assetPrice + ? `$${priceValueUsd || "0.00"}` + : "--" + : `${priceValue || "0"} ${ + srcAsset ? srcAsset.code : "" + }` + } + isAmountTooHigh={isAmountTooHigh} + maxSpendableText={displayTotal} + cryptoDecimals={assetDecimals} + onAmountChange={({ amount: 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 }) => { + const v = newAmount === "" ? "0.00" : newAmount; + formik.setFieldValue("amountUsd", v); + dispatch(saveAmountUsd(v)); + }} + 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(); + }} + /> +
+
+ { emitMetric(METRIC_NAMES.swapAmount); - if (inputType === "fiat") { - const availableUsd = formatAmount( + const fraction = new BigNumber(pct).dividedBy(100); + if (inputType === "fiat" && assetPrice) { + const pctUsd = formatAmount( roundUsdValue( - new BigNumber(assetPrice!) + new BigNumber(assetPrice) .multipliedBy( new BigNumber(cleanAmount(availableBalance)), ) + .multipliedBy(fraction) .toString(), ), ); - formik.setFieldValue("amountUsd", availableUsd); - dispatch(saveAmountUsd(availableUsd)); + formik.setFieldValue("amountUsd", pctUsd); + dispatch(saveAmountUsd(pctUsd)); } else { - formik.setFieldValue("amount", availableBalance); - dispatch(saveAmount(availableBalance)); + const pctAmount = new BigNumber( + cleanAmount(availableBalance), + ) + .multipliedBy(fraction) + .decimalPlaces(assetDecimals) + .toString(); + formik.setFieldValue("amount", pctAmount); + dispatch(saveAmount(pctAmount)); } }} - data-testid="SwapAssetSetMax" - > - {t("Set Max")} - + />
- -
@@ -648,6 +1130,9 @@ export const SwapAmount = ({ fee={fee} networkDetails={networkDetails} onCancel={() => setIsReviewingTx(false)} + // 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} @@ -660,6 +1145,26 @@ export const SwapAmount = ({ amount: destinationAmount, }} title={t("You are swapping")} + destinationTokenDetails={transactionData.destinationTokenDetails} + sourceTokenSecurityLevel={sourceTokenSecurityLevel} + sourceTokenSecurityWarnings={sourceTokenSecurityWarnings} + /> + ) : ( + <> + )} + + setIsXlmReserveOpen(false)} + isModalOpen={isXlmReserveOpen} + > + {isXlmReserveOpen ? ( + setIsXlmReserveOpen(false)} + publicKey={publicKey} + canSwapForReserve={canSwapForReserve} + helpUrl={XLM_RESERVE_HELP_URL} + tokenCode={dstAsset ? dstAsset.code : ""} + onSwapForReserve={handleSwapForReserve} /> ) : ( <> diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index 4ef2de839c..3fe0cf9f54 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -25,6 +25,59 @@ } } + &__cards { + width: 100%; + + // The swap layout owns inter-card spacing (an 8px seam with an overlapping + // chevron), so neutralize AmountCard's own vertical margins here. + .AmountCard { + margin: 0; + } + } + + // An 8px seam between the cards; the circular direction toggle is centered on + // it and overlaps into each card (its size is set on __direction-btn below). + &__direction { + position: relative; + height: pxToRem(8px); + z-index: 1; + } + + &__direction-btn { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + // Slightly smaller than the cards' gap so it doesn't overlap the sell + // card's fiat-toggle (RefreshCw03) button (§ batch3 task 9; § batch4 task 6). + width: pxToRem(30px); + height: pxToRem(30px); + padding: 0; + border: none; + border-radius: pxToRem(100px); + // Between the dark page background and the cards (gray-03) so the toggle + // stays distinct from both. + background-color: var(--sds-clr-gray-02); + color: var(--sds-clr-gray-12); + cursor: pointer; + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + } + + &:hover { + background-color: var(--sds-clr-gray-04); + } + } + + &__percentage-buttons { + margin-top: pxToRem(12px); + } + &__simplebar { margin-top: 1rem; @@ -154,6 +207,7 @@ &__settings-fee-display { display: flex; + font-size: pxToRem(14px); svg { margin-right: pxToRem(6px); 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..49acf907fb --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/InfoSheets/__tests__/InfoSheets.test.tsx @@ -0,0 +1,46 @@ +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("Verified token")).toBeInTheDocument(); + expect( + screen.getByText( + "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 assets are not on any of your lists. Proceed with caution before adding.", + ), + ).toBeInTheDocument(); + }); + + it("VerifiedTokenInfoSheet calls onClose when dismiss button is clicked", () => { + const mockOnClose = jest.fn(); + render(); + const dismissButton = screen.getByText("Close"); + fireEvent.click(dismissButton); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("UnverifiedTokenInfoSheet calls onClose when dismiss button is clicked", () => { + const mockOnClose = jest.fn(); + render(); + 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 new file mode 100644 index 0000000000..acdb716846 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/InfoSheets/index.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@stellar/design-system"; + +import { InfoBottomSheet } from "popup/components/InfoBottomSheet"; + +interface InfoSheetProps { + isOpen: boolean; + onClose: () => void; +} + +export const VerifiedTokenInfoSheet = ({ isOpen, onClose }: InfoSheetProps) => { + const { t } = useTranslation(); + return ( + } + 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.", + )} + + ); +}; + +export const UnverifiedTokenInfoSheet = ({ + isOpen, + onClose, +}: InfoSheetProps) => { + const { t } = useTranslation(); + return ( + } + 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/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..d2111f1d03 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -0,0 +1,235 @@ +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. +// "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 }) => ( +
+ ), +})); + +jest.mock("popup/components/AssetListRow", () => ({ + AssetListRow: ({ 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", + // 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", () => { + 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("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("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( + , + ); + + expect( + screen.getByTestId("swap-picker-fallback-notice"), + ).toBeInTheDocument(); + }); + + it("keeps held Your tokens visible but excludes hiddenAssets from discover sections", () => { + render( + , + ); + + // "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(); + // 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)", () => { + 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..8db646a0a3 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx @@ -0,0 +1,274 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon, Notification } from "@stellar/design-system"; + +import { SwapTokenMenu } from "../SwapTokenMenu"; +import { BalanceRow } from "popup/components/BalanceRow"; +import { + VerifiedTokenInfoSheet, + UnverifiedTokenInfoSheet, +} from "../InfoSheets"; +import { AssetListRow } from "popup/components/AssetListRow"; +import { isContractId } from "popup/helpers/soroban"; +import { SecurityLevel } from "popup/constants/blockaid"; +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, + securityWarnings: r.securityWarnings, + iconUrl: r.image ?? r.icon ?? undefined, + spotPrice: r.spotPrice, + source, +}); + +/** + * 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; + /** Canonicals to exclude from every section (e.g. the swap source asset, so + * a user can't pick the same token as both sides). */ + hiddenAssets?: string[]; + onClickAsset: ( + canonical: string, + isContract: boolean, + details?: SwapPickerSelection, + ) => 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 = ({ + result, + searchTerm, + hiddenAssets = [], + onClickAsset, + stellarExpertUrl, + isDestination, +}: SwapPickerSectionsProps) => { + const { t } = useTranslation(); + const [verifiedSheetOpen, setVerifiedSheetOpen] = useState(false); + const [unverifiedSheetOpen, setUnverifiedSheetOpen] = useState(false); + + const isSearching = searchTerm.trim().length > 0; + + // 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; + 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)); + + // 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. + const renderDiscoverRows = ( + records: SwapTokenRecord[], + source: PickerSource, + ) => + records.map((r) => { + const code = r.code ?? ""; + const isSuspicious = + r.securityLevel === SecurityLevel.MALICIOUS || + r.securityLevel === SecurityLevel.SUSPICIOUS; + return ( + + onClickAsset(r.canonical, r.isContract, buildSelection(r, source)) + } + rightElement={ + + } + /> + ); + }); + + const hasResults = isSearching + ? yourTokens.length + verified.length + unverified.length > 0 + : (result.isNewAccount ? 0 : yourTokens.length) + popular.length > 0; + + return ( +
+ {result.isFallback && ( +
+ +
+ )} + + {!hasResults ? ( + isDestination && + (result.hadSorobanMatches || isContractId(searchTerm.trim())) ? ( +
+ {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 && yourTokens.length > 0 && ( + <> +
+ {t("Your tokens")} +
+ {renderBalanceRows(yourTokens, "balances")} + + )} + + {!isSearching && popular.length > 0 && ( + <> +
+ {t("Popular tokens")} +
+ {renderDiscoverRows(popular, "popular")} + + )} + + {isSearching && verified.length > 0 && ( + <> +
+ {t("Verified")} + +
+ {renderDiscoverRows(verified, "search")} + + )} + + {isSearching && unverified.length > 0 && ( + <> +
+ + {t("Unverified")} + + +
+ {renderDiscoverRows(unverified, "search")} + + )} + + )} + + 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..d2c99b0f86 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/styles.scss @@ -0,0 +1,56 @@ +.SwapPickerSections { + 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; + } + + &__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.875rem; + + &__info { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + color: var(--sds-clr-gray-09); + display: inline-flex; + } + } + + // 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 { + 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; + // A pasted address has no spaces, so wrap it across lines instead of + // letting the 56-char string overflow the card (§ task 3). + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + } +} 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; + } +} 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..a0d300b828 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } 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 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("swap-picker-sections")).toBeInTheDocument(); + expect(screen.queryByTestId("token-list")).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(); + }); + + 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", + }); + }); + + it("destination: typing clears all results and shows the loader until the lookup settles", async () => { + 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(); + + // ...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)", () => { + 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" } }, + 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/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/__tests__/useSwapTokenLookup.abort.test.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.abort.test.tsx new file mode 100644 index 0000000000..edb595cfbc --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.abort.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { Provider } from "react-redux"; +import { renderHook, act } from "@testing-library/react"; + +// The hook reads the App store singleton directly for the token-list / popular +// caches; stub it with empty (but well-shaped) cache slices. +jest.mock("popup/App", () => ({ + store: { + getState: () => ({ cache: { tokenLists: [], popularTokens: {} } }), + }, +})); +// No verified lists cached -> the hook fetches them; return empty so it proceeds +// straight to the search request. +jest.mock("@shared/api/helpers/token-list", () => ({ + getCombinedAssetListData: jest.fn().mockResolvedValue([]), +})); +jest.mock("popup/helpers/searchAsset", () => ({ + searchAsset: jest.fn().mockResolvedValue({ _embedded: { records: [] } }), +})); +jest.mock("popup/helpers/assetList", () => ({ + splitVerifiedAssetCurrency: jest.fn(), +})); + +import { splitVerifiedAssetCurrency } from "popup/helpers/assetList"; +import { makeDummyStore } from "popup/__testHelpers__"; +import { + useSwapTokenLookup, + resetSwapIdleCacheForTests, +} from "../useSwapTokenLookup"; + +const TESTNET = { + // TESTNET supports discovery but has Blockaid disabled, so the search path + // runs without the bulk scan — isolating the first FETCH_DATA_SUCCESS guard. + network: "TESTNET", + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: "https://horizon-testnet.stellar.org", +} as any; + +const deferred = () => { + let resolve: (value: unknown) => void = () => {}; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +describe("useSwapTokenLookup fetchData — abort race (§ batch4 task 7)", () => { + afterEach(() => { + jest.clearAllMocks(); + resetSwapIdleCacheForTests(); + }); + + it("does not commit a superseded search's sections over the current one", async () => { + const store = makeDummyStore({ settings: { assetsLists: {} } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSwapTokenLookup(), { wrapper }); + + // First split call belongs to the superseded ("stale") lookup, the second + // to the current ("fresh") one (FIFO: the stale call is invoked first). + const staleSplit = deferred(); + const freshSplit = deferred(); + (splitVerifiedAssetCurrency as jest.Mock) + .mockReturnValueOnce(staleSplit.promise) + .mockReturnValueOnce(freshSplit.promise); + + const baseArgs = { + balances: [], + publicKey: "GADUMMY", + networkDetails: TESTNET, + }; + + // Kick off both lookups; the second aborts the first synchronously. + let stalePromise: Promise; + let freshPromise: Promise; + await act(async () => { + stalePromise = result.current.fetchData({ + ...baseArgs, + searchTerm: "stale", + }); + freshPromise = result.current.fetchData({ + ...baseArgs, + searchTerm: "fresh", + }); + }); + + // The current ("fresh") lookup resolves first and commits its sections. + await act(async () => { + freshSplit.resolve({ + verifiedAssets: [{ code: "FRESH", issuer: "GFRESH", domain: null }], + unverifiedAssets: [], + }); + await freshPromise; + }); + + expect( + (result.current.state.data?.sections.verified ?? []).map((r) => r.code), + ).toContain("FRESH"); + + // Now the superseded ("stale") lookup resolves last — its dispatch must be + // dropped, not painted over the fresh sections (the "Your tokens" flash). + await act(async () => { + staleSplit.resolve({ + verifiedAssets: [{ code: "STALE", issuer: "GSTALE", domain: null }], + unverifiedAssets: [], + }); + await stalePromise; + }); + + const verifiedCodes = ( + result.current.state.data?.sections.verified ?? [] + ).map((r) => r.code); + expect(verifiedCodes).toContain("FRESH"); + expect(verifiedCodes).not.toContain("STALE"); + }); +}); 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..cef55150a9 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts @@ -0,0 +1,227 @@ +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"]); + }); + + 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"); + }); + + 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", () => { + 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); + }); + + it("stamps friendly securityWarnings from the scan's Warning/Malicious features (excluding Benign)", () => { + 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", + features: [ + { type: "Malicious", feature_id: "mal", description: "bad thing" }, + { type: "Benign", feature_id: "ok", description: "fine thing" }, + ], + }, + } as any, + networkDetails: MAINNET, + }); + expect(merged[0].securityWarnings).toEqual([ + { description: "bad thing", isError: true, featureId: "mal" }, + ]); + }); + + it("omits securityWarnings when the scan has no flagged features", () => { + 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].securityWarnings).toBeUndefined(); + }); +}); 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 b6ab94033b..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 { @@ -103,28 +104,17 @@ 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. + // 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, 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..fb52257700 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -0,0 +1,727 @@ +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 { 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"; + +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 { BlockaidWarning, 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"; +import { sortBalancesByValue } from "popup/helpers/balance"; +import { + scanAssetBulk, + isAssetMalicious, + isAssetSuspicious, + shouldTreatAssetAsUnableToScan, + isBlockaidEnabled, + extractAssetScanWarnings, +} from "popup/helpers/blockaid"; +import { settingsSelector } from "popup/ducks/settings"; +import { + tokensListsSelector, + saveTokenLists, + popularTokensSelector, + savePopularTokens, + 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; + /** Friendly per-feature Blockaid reasons from the token's asset scan, carried + * to the review's "Do not proceed" pane (§ batch4 task 3). */ + securityWarnings?: BlockaidWarning[]; + /** Formatted held-token balance (held rows only). */ + tokenAmount?: string; + fiatValue?: string; + percentChange24h?: string; + /** USD spot price from the stellar.expert search result (non-held tokens), + * used as a fallback when /token-prices has no entry. No 24h % available. */ + spotPrice?: number; +} + +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, + icons: Record = {}, + tokenPrices: ApiTokenPrices = {}, +): SwapTokenRecord | null => { + // 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 { + 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); + + // 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 tokenAmount = formatAmount(total.toFixed()); + 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, + 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, + tokenAmount, + fiatValue, + percentChange24h, + isHeld: true, + isContract: false, + requiresTrustline: false, + }; +}; + +/** + * 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[] => + // 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); + +/** + * 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, + spotPrice: asset.price, + }; +}; + +/** + * 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, + popular = [], + verifiedAssets = [], + unverifiedAssets = [], + searchResults = [], + isFallback = false, + icons = {}, + tokenPrices = {}, +}: { + 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[]; + unverifiedAssets?: ManageAssetCurrency[]; + searchResults?: ManageAssetCurrency[]; + isFallback?: boolean; + icons?: Record; + tokenPrices?: ApiTokenPrices; +}): SwapTokenLookupResult => { + const term = searchTerm.trim().toLowerCase(); + const isSearch = term.length > 0; + + const heldRecords = balancesToHeldRecords({ balances, icons, tokenPrices }); + const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); + + // 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 => { + const isSorobanContract = + (asset.contract && isContractId(asset.contract)) || + (asset.issuer && isContractId(asset.issuer)); + if (isSorobanContract) { + 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; + } + // Keep the friendly per-feature reasons so the review can show them + // alongside the transaction-scan reasons (§ batch4 task 3). + const securityWarnings = extractAssetScanWarnings(scan); + return { + ...row, + securityLevel, + ...(securityWarnings.length ? { securityWarnings } : {}), + }; + }); + +// ---- helper to convert a Stellar Expert search record to ManageAssetCurrency ---- + +interface AssetSearchRecord { + asset: string; + domain?: string; + code?: string; + token_name?: string; + decimals?: number; + /** USD spot price (stellar.expert). */ + price?: 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, + price: record.price, + }; + } + return { + code: record.asset.split("-")[0], + issuer: record.asset.split("-")[1], + domain: record.domain ?? null, + image: record.tomlInfo?.image, + price: record.price, + }; +}; + +// 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(); + +// Module-scoped cache of the last successful SEARCH result per network, keyed +// by the normalized search term. Same lifetime as the idle cache (in-memory, +// dies on popup close): re-running a search the user already ran in this +// session repaints instantly and revalidates silently, instead of re-hitting +// stellar.expert + the Blockaid bulk scan from scratch (§ batch3 task 10). +const swapSearchResultCacheByNetwork = new Map< + string, + Map +>(); + +const getCachedSearchResult = (network: string, term: string) => + swapSearchResultCacheByNetwork.get(network)?.get(term); + +const setCachedSearchResult = ( + network: string, + term: string, + result: SwapTokenLookupResult, +) => { + let byTerm = swapSearchResultCacheByNetwork.get(network); + if (!byTerm) { + byTerm = new Map(); + swapSearchResultCacheByNetwork.set(network, byTerm); + } + byTerm.set(term, result); +}; + +/** Test-only: clear the module-scoped idle + search caches between tests. */ +export const resetSwapIdleCacheForTests = () => { + swapIdleResultCacheByNetwork.clear(); + swapSearchResultCacheByNetwork.clear(); +}; + +// ---- 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, + icons = {}, + tokenPrices = {}, + }: { + searchTerm: string; + balances: AssetType[]; + publicKey: string; + networkDetails: NetworkDetails; + icons?: Record; + tokenPrices?: ApiTokenPrices; + }): Promise => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const { signal } = controller; + + // On idle re-entry — or a repeated search the user already ran this session + // — repaint instantly from the last cached result and revalidate silently + // (no spinner); otherwise show the spinner while it loads (§1.10, § batch3 + // task 10). + const isIdle = !searchTerm.trim(); + const normalizedTerm = searchTerm.trim().toLowerCase(); + const cachedIdleResult = isIdle + ? swapIdleResultCacheByNetwork.get(networkDetails.network) + : undefined; + const cachedResult = + cachedIdleResult || + (isIdle + ? undefined + : getCachedSearchResult(networkDetails.network, normalizedTerm)); + if (cachedResult) { + dispatch({ type: "FETCH_DATA_SUCCESS", payload: cachedResult }); + } else { + 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, + icons, + tokenPrices, + 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. 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; + + 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. + // 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, + icons, + tokenPrices, + popular, + verifiedAssets, + unverifiedAssets, + searchResults, + }); + + // A newer lookup (the user typed again or cleared the box) already + // superseded this one. Some awaits above don't take the signal + // (splitVerifiedAssetCurrency), so a superseded call can still reach here + // and commit its stale sections over the current render — the "Your + // tokens" flash. Drop it (§ batch4 task 7). + if (signal.aborted) { + return; + } + + 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) { + 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, + }), + }, + }; + // Same guard as the first dispatch: don't repaint a superseded + // lookup's Blockaid-decorated sections over the current one. + if (signal.aborted) { + return; + } + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + } + } + } + + // Cache the fully-decorated result for instant repaint + stale-serve: + // idle keyed by network, search keyed by network + normalized term. + if (isIdle) { + swapIdleResultCacheByNetwork.set(networkDetails.network, payload); + } else { + setCachedSearchResult(networkDetails.network, normalizedTerm, payload); + } + } catch (e) { + if (signal.aborted) { + // Cancelled — silently ignore (another call is already in flight) + return; + } + captureException(`useSwapTokenLookup fallback - ${JSON.stringify(e)}`); + // Serve the last good cached result (idle or this search term) 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 (cachedResult) { + dispatch({ type: "FETCH_DATA_SUCCESS", payload: cachedResult }); + return; + } + // Graceful fallback: stellar.expert or Blockaid unreachable → held-only + dispatch({ + type: "FETCH_DATA_SUCCESS", + payload: buildSwapSections({ + searchTerm, + balances, + networkDetails, + icons, + tokenPrices, + isFallback: true, + }), + }); + } + }; + + return { fetchData, state }; +}; diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index 36d2f9a9a3..5f440cecd8 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -4,8 +4,8 @@ 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"; import { openTab } from "popup/helpers/navigate"; import { View } from "popup/basics/layout/View"; @@ -14,35 +14,113 @@ 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 type { DestinationTokenDetails } from "popup/ducks/transactionSubmission"; import { useGetSwapFromData } from "./hooks/useSwapFromData"; +import { + useSwapTokenLookup, + balancesToHeldRecords, +} from "./hooks/useSwapTokenLookup"; +import { SwapPickerSections } from "./SwapPickerSections"; +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 { - title: string; + selectionType: "source" | "destination"; hiddenAssets: string[]; - onClickAsset: (canonical: string, isContract: boolean) => void; + onClickAsset: ( + canonical: string, + isContract: boolean, + details?: SwapPickerSelection, + ) => 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; + // 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. This flag + // shows the loader from the keystroke until the lookup STARTS (bridging only + // the debounce gap); from there isLoading (lookupState LOADING) takes over and + // clears as soon as the first results paint. We deliberately don't keep it set + // for the whole fetch promise — that kept the spinner up through the silent + // Blockaid revalidation / cached-result repaint (§ batch3 tasks 10 & 11). + const [isSearchPending, setIsSearchPending] = React.useState(false); + + 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: async (values) => { + if (isDestination) { + const resolvedFrom = fromState.data; + const balances = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.balances.balances + : []; + const publicKey = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.publicKey + : ""; + const icons = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.balances.icons + : {}; + const tokenPrices = + resolvedFrom?.type === AppDataType.RESOLVED + ? resolvedFrom.tokenPrices + : {}; + // The lookup is starting now (the debounce gap is over): hand the + // loading indicator off to isLoading, which clears at the first + // results dispatch. lookupFetchData synchronously dispatches its first + // action (LOADING, or an instant cached SUCCESS), so the loader state + // is correct in the same render — a cached/idle result shows at once + // and the spinner never waits for the silent revalidation. + setIsSearchPending(false); + await lookupFetchData({ + searchTerm: values.searchTerm, + balances, + publicKey, + networkDetails, + icons, + tokenPrices, + }); + } else { + filterBalances(values.searchTerm); + } + }, validateOnChange: false, }); @@ -51,48 +129,129 @@ export const SwapAsset = ({ debounce(() => { formik.submitForm(); }, 300), - [formik], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], ); const handleChange = (e: React.ChangeEvent) => { const val = e.target.value; formik.setFieldValue("searchTerm", val); + // The destination lookup is async (debounced + network); show the loader + // immediately so stale results clear at once. It's handed off to isLoading + // once the debounced lookup starts. The source filter is synchronous, so it + // doesn't need this. + if (isDestination) { + setIsSearchPending(true); + } 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 + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only balance fetch; fetchData is stable for this purpose }, []); - const hasError = state.state === RequestState.ERROR; - if (state.data?.type === AppDataType.REROUTE) { - if (state.data.shouldOpenTab) { - openTab(newTabHref(state.data.routeTarget)); - window.close(); + // 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) { + return; } - return ( - - ); - } - - if (!hasError && !isLoading) { - reRouteOnboarding({ - type: state.data.type, - applicationState: state.data?.applicationState, - state: state.state, + 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, + 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]); + + // 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 ( + + ); + } + + 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, + }; + + // 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 ( <> @@ -117,17 +276,27 @@ export const SwapAsset = ({ />
- {isLoading ? ( + {isLoading || isSearchPending ? (
+ ) : isDestination ? ( + ) : ( - )}
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..fc9248463e --- /dev/null +++ b/extension/src/popup/components/swap/XlmReserveSheet/__tests__/XlmReserveSheet.test.tsx @@ -0,0 +1,87 @@ +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); + }); + + 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 new file mode 100644 index 0000000000..ca44329e8f --- /dev/null +++ b/extension/src/popup/components/swap/XlmReserveSheet/index.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +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"; + +interface XlmReserveSheetProps { + canSwapForReserve: boolean; + onSwapForReserve?: () => void; + publicKey: string; + helpUrl: string; + /** Destination token code, interpolated into the body + reserve copy. */ + tokenCode?: string; + onClose: () => void; +} + +export const XlmReserveSheet = ({ + canSwapForReserve, + onSwapForReserve, + publicKey, + helpUrl, + tokenCode = "", + onClose, +}: XlmReserveSheetProps) => { + const { t } = useTranslation(); + + return ( +
+
+
+ +
+ +
+ +
+

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

+ {/* A div, not a p: the SDS theme's global `p` rules force + color:inherit !important (→ near-white) and line-height 1.75rem, + overriding the muted gray + 20px we want (§ batch4 task 2). */} +
+ {t( + "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 ? ( + + ) : 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..34cd5a8780 --- /dev/null +++ b/extension/src/popup/components/swap/XlmReserveSheet/styles.scss @@ -0,0 +1,140 @@ +@use "../../../styles/utils.scss" as *; + +.XlmReserveSheet { + display: flex; + flex-direction: column; + 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); + // Brand/Foreground/Primary — muted vs the brighter lilac-11 (§ batch3 t7d). + color: var(--sds-clr-lilac-09); + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + } + } + + &__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); + // Default/Foreground/Primary — muted vs the brighter gray-11 (§ batch3 t7d). + color: var(--sds-clr-gray-09); + 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(--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: pxToRem(12px); + + // CopyText nests the copy button under .Floater / .CopyText__content + // wrappers that otherwise shrink to content width; stretch the chain so the + // copy button is full-width like the swap button (§ batch3 task 7b). + .CopyText, + .Floater, + .CopyText__content { + width: 100%; + } + } +} 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/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts new file mode 100644 index 0000000000..d93bd5a0f0 --- /dev/null +++ b/extension/src/popup/constants/__tests__/metricsNames.test.ts @@ -0,0 +1,16 @@ +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"); + expect(METRIC_NAMES.swapSuccess).toBe("swap: success"); + }); +}); diff --git a/extension/src/popup/constants/blockaid.ts b/extension/src/popup/constants/blockaid.ts index ca96890d93..0b9ecd96f5 100644 --- a/extension/src/popup/constants/blockaid.ts +++ b/extension/src/popup/constants/blockaid.ts @@ -8,3 +8,51 @@ export enum SecurityLevel { MALICIOUS = "MALICIOUS", UNABLE_TO_SCAN = "UNABLE_TO_SCAN", } + +/** + * A single friendly Blockaid reason extracted from a token/asset scan's + * `features[]`. Carried alongside the SecurityLevel so the review screen can + * list these reasons next to the transaction-scan reasons. + */ +export interface BlockaidWarning { + // The human-readable feature description, e.g. "An identified malicious + // address is associated with the token." + description: string; + // true for "Malicious" features, false for "Warning" (suspicious) ones. + isError: boolean; + // Blockaid's feature_id, used to dedupe against the transaction-scan reasons. + featureId?: string; +} + +// 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/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 35821e541d..97574adccd 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -77,6 +77,14 @@ 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", + swapSuccess: "swap: success", viewAddCollectibles: "loaded screen: add collectibles", viewSendCollectible: "loaded screen: send collectible", 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..f0a8342779 --- /dev/null +++ b/extension/src/popup/ducks/__tests__/cache.popular.test.ts @@ -0,0 +1,27 @@ +import { reducer, savePopularTokens, 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", () => { + 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("clearAll resets popularTokens", () => { + let state = reducer( + undefined, + savePopularTokens({ networkDetails: MAINNET, tokens: trending }), + ); + state = reducer(state, clearAll()); + expect(state.popularTokens).toEqual({}); + }); +}); 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..2fd0c3a227 --- /dev/null +++ b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts @@ -0,0 +1,129 @@ +import { configureStore } from "@reduxjs/toolkit"; + +import { SecurityLevel } from "popup/constants/blockaid"; +import { + reducer as transactionSubmissionReducer, + saveDestinationTokenDetails, + destinationTokenDetailsSelector, + clearSwapQuoteExpired, + resetSubmitStatus, + resetSubmission, + submitFreighterTransaction, + initialState, + DestinationTokenDetails, +} from "../transactionSubmission"; + +const makeStore = () => + configureStore({ + reducer: { transactionSubmission: transactionSubmissionReducer }, + }); + +const rejectedWith = (operations: string[]) => ({ + type: submitFreighterTransaction.rejected.type, + payload: { response: { extras: { result_codes: { operations } } } }, +}); + +const quoteExpiredFlag = (store: ReturnType) => + store.getState().transactionSubmission.isSwapQuoteExpired; + +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(); + }); +}); + +describe("transactionSubmission slippage default", () => { + it('defaults allowedSlippage to "2" (matching mobile)', () => { + expect(initialState.transactionData.allowedSlippage).toBe("2"); + }); +}); + +describe("transactionSubmission isSwapQuoteExpired", () => { + it("defaults to false", () => { + expect(initialState.isSwapQuoteExpired).toBe(false); + }); + + it("flags a quote-expiry op code on submit rejection", () => { + const store = makeStore(); + store.dispatch(rejectedWith(["op_under_dest_min"])); + expect(quoteExpiredFlag(store)).toBe(true); + }); + + it("does not flag a generic submit failure", () => { + const store = makeStore(); + store.dispatch(rejectedWith(["op_underfunded"])); + expect(quoteExpiredFlag(store)).toBe(false); + }); + + it("clearSwapQuoteExpired resets the flag", () => { + const store = makeStore(); + store.dispatch(rejectedWith(["op_too_few_offers"])); + expect(quoteExpiredFlag(store)).toBe(true); + store.dispatch(clearSwapQuoteExpired()); + expect(quoteExpiredFlag(store)).toBe(false); + }); + + it("resetSubmitStatus keeps the flag (it drives the review-screen notice)", () => { + const store = makeStore(); + store.dispatch(rejectedWith(["op_under_dest_min"])); + store.dispatch(resetSubmitStatus()); + expect(quoteExpiredFlag(store)).toBe(true); + }); + + it("resetSubmission clears the flag", () => { + const store = makeStore(); + store.dispatch(rejectedWith(["op_under_dest_min"])); + store.dispatch(resetSubmission()); + expect(quoteExpiredFlag(store)).toBe(false); + }); +}); diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 93ccaeaca0..bf0ef88aa8 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -5,6 +5,7 @@ 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 { TrendingAsset } from "popup/helpers/trendingAssets"; type AssetCode = string; type PublicKey = string; @@ -52,6 +53,14 @@ type SaveCollectionsPayload = { collections: Collection[]; }; +type SavePopularTokensPayload = { + networkDetails: NetworkDetails; + tokens: TrendingAsset[]; +}; + +// ~30-min staleness window for the Popular list Redux cache (§2.8). +export const POPULAR_TOKENS_STALE_MS = 30 * 60 * 1000; + interface InitialState { balanceData: { [network: string]: Record< @@ -72,6 +81,9 @@ interface InitialState { [publicKey: string]: ApiTokenPrices & { updatedAt: number }; }; collections: { [network: string]: Record }; + popularTokens: { + [network: string]: { tokens: TrendingAsset[]; updatedAt: number }; + }; } const initialState: InitialState = { @@ -83,6 +95,7 @@ const initialState: InitialState = { historyData: {}, tokenPrices: {}, collections: {}, + popularTokens: {}, }; const cacheSlice = createSlice({ @@ -97,6 +110,7 @@ const cacheSlice = createSlice({ state.tokenDetails = {}; state.historyData = {}; state.tokenPrices = {}; + state.popularTokens = {}; }, saveBalancesForAccount(state, action: { payload: SaveBalancesPayload }) { state.balanceData = { @@ -179,6 +193,15 @@ const cacheSlice = createSlice({ ]; } }, + savePopularTokens(state, action: { payload: SavePopularTokensPayload }) { + state.popularTokens = { + ...state.popularTokens, + [action.payload.networkDetails.network]: { + tokens: action.payload.tokens, + updatedAt: Date.now(), + }, + }; + }, }, }); @@ -200,6 +223,8 @@ 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 { reducer } = cacheSlice; export const { @@ -214,4 +239,5 @@ export const { saveCollections, clearBalancesForAccount, clearCollectiblesForAccount, + savePopularTokens, } = cacheSlice.actions; diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 65b04aa8a6..474aa6cb52 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -28,10 +28,12 @@ import { import { NETWORKS, NetworkDetails } from "@shared/constants/stellar"; import { ConfigurableWalletType } from "@shared/constants/hardwareWallet"; import { isCustomNetwork } from "@shared/helpers/stellar"; +import { BlockaidWarning, SecurityLevel } from "popup/constants/blockaid"; import { getCanonicalFromAsset } from "helpers/stellar"; import { INDEXER_URL } from "@shared/constants/mercury"; import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; +import { isQuoteExpiredError } from "popup/helpers/quoteExpiry"; import { soroswapGetBestPath, getSoroswapTokens as getSoroswapTokensService, @@ -416,6 +418,28 @@ 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; + // friendly per-feature Blockaid reasons from the pick-time token scan, shown + // in the review's "Do not proceed" pane alongside the tx-scan reasons. + securityWarnings?: BlockaidWarning[]; + // from the search/Popular record, before balances hydrate. + iconUrl?: string; + // USD spot price from the stellar.expert search result, used as a fallback + // for the "You receive" fiat when /token-prices has no entry. No 24h %. + spotPrice?: number; +} + interface TransactionData { amount: string; amountUsd: string; @@ -432,6 +456,7 @@ interface TransactionData { destinationDecimals?: number; destinationAmount: string; destinationIcon: string; + destinationTokenDetails: DestinationTokenDetails | null; path: string[]; allowedSlippage: string; isToken: boolean; @@ -472,6 +497,10 @@ interface InitialState { | SorobanRpc.Api.SendTransactionResponse | null; error: ErrorMessage | undefined; + // Set when a swap submission fails because the frozen quote no longer clears + // on-chain (op_under_dest_min / op_too_few_offers). Drives the recover-and- + // retry flow instead of the terminal SubmitFail screen (§2.1/§3.3). + isSwapQuoteExpired: boolean; transactionData: TransactionData; transactionSimulation: { response: SorobanRpc.Api.SimulateTransactionSuccessResponse | null; @@ -489,6 +518,7 @@ export const initialState: InitialState = { submitStatus: ActionStatus.IDLE, response: null, error: undefined, + isSwapQuoteExpired: false, transactionData: { amount: "0", amountUsd: "0.00", @@ -503,8 +533,9 @@ export const initialState: InitialState = { destinationAsset: "", destinationAmount: "", destinationIcon: "", + destinationTokenDetails: null, path: [], - allowedSlippage: "1", + allowedSlippage: "2", isCollectible: false, collectibleData: { collectionName: "", @@ -544,6 +575,9 @@ const transactionSubmissionSlice = createSlice({ resetSubmitStatus: (state) => { state.submitStatus = initialState.submitStatus; }, + clearSwapQuoteExpired: (state) => { + state.isSwapQuoteExpired = false; + }, saveDestination: (state, action) => { state.transactionData.destination = action.payload; }, @@ -656,6 +690,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) => { @@ -664,6 +704,10 @@ const transactionSubmissionSlice = createSlice({ builder.addCase(submitFreighterTransaction.rejected, (state, action) => { state.submitStatus = ActionStatus.ERROR; state.error = action.payload; + // Flag a swap quote-expiry so the Swap flow recovers (refetch + retry) + // instead of dead-ending in SubmitFail (§2.1/§3.3). These op codes only + // arise from swap path payments, so this is a no-op for sends. + state.isSwapQuoteExpired = isQuoteExpiredError(action.payload); }); builder.addCase(submitFreighterTransaction.fulfilled, (state, action) => { state.submitStatus = ActionStatus.SUCCESS; @@ -765,6 +809,7 @@ const transactionSubmissionSlice = createSlice({ export const { resetSubmission, resetSubmitStatus, + clearSwapQuoteExpired, saveDestination, saveRecipientName, saveFederationAddress, @@ -791,6 +836,7 @@ export const { saveIsMergeSelected, saveBalancesToMigrate, saveSwapBestPath, + saveDestinationTokenDetails, } = transactionSubmissionSlice.actions; export const { reducer } = transactionSubmissionSlice; @@ -805,3 +851,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/helpers/__tests__/blockaid.test.tsx b/extension/src/popup/helpers/__tests__/blockaid.test.tsx index 53bacbeb2c..80562150a0 100644 --- a/extension/src/popup/helpers/__tests__/blockaid.test.tsx +++ b/extension/src/popup/helpers/__tests__/blockaid.test.tsx @@ -11,6 +11,7 @@ import { useShouldTreatTxAsUnableToScan, isBlockaidEnabled, getSiteSecurityStates, + extractAssetScanWarnings, } from "../blockaid"; import { SecurityLevel } from "popup/constants/blockaid"; import { @@ -519,3 +520,44 @@ describe("BlockAid Helper Functions", () => { }); }); }); + +describe("extractAssetScanWarnings", () => { + it("returns only Warning/Malicious features, excluding Benign/Info", () => { + const scan = { + features: [ + { type: "Malicious", feature_id: "mal", description: "bad thing" }, + { type: "Warning", feature_id: "warn", description: "iffy thing" }, + { type: "Benign", feature_id: "ok", description: "fine thing" }, + { type: "Info", feature_id: "info", description: "fyi thing" }, + ], + } as unknown as BlockAidScanAssetResult; + + expect(extractAssetScanWarnings(scan)).toEqual([ + { description: "bad thing", isError: true, featureId: "mal" }, + { description: "iffy thing", isError: false, featureId: "warn" }, + ]); + }); + + it("maps isError from the feature type (Malicious -> true, Warning -> false)", () => { + const scan = { + features: [ + { type: "Malicious", feature_id: "a", description: "m" }, + { type: "Warning", feature_id: "b", description: "w" }, + ], + } as unknown as BlockAidScanAssetResult; + + const result = extractAssetScanWarnings(scan); + expect(result[0].isError).toBe(true); + expect(result[1].isError).toBe(false); + }); + + it("returns [] for null/undefined/empty features", () => { + expect(extractAssetScanWarnings(null)).toEqual([]); + expect(extractAssetScanWarnings(undefined)).toEqual([]); + expect( + extractAssetScanWarnings({ + features: [], + } as unknown as BlockAidScanAssetResult), + ).toEqual([]); + }); +}); 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/__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/__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/__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/__tests__/searchAsset.test.ts b/extension/src/popup/helpers/__tests__/searchAsset.test.ts new file mode 100644 index 0000000000..6507430e72 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/searchAsset.test.ts @@ -0,0 +1,40 @@ +import { searchAsset } from "../searchAsset"; +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; + +describe("searchAsset", () => { + afterEach(() => jest.restoreAllMocks()); + + it("returns the parsed body on a 2xx response", async () => { + const body = { _embedded: { records: [{ asset: "USDC-GUSD" }] } }; + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => body, + } as unknown as Response); + + const result = await searchAsset({ + asset: "usdc", + networkDetails: MAINNET, + }); + expect(result).toEqual(body); + }); + + it("throws on a non-ok response instead of returning a non-records body", async () => { + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + statusText: "Bad Gateway", + json: async () => ({ error: "upstream" }), + } as unknown as Response); + + await expect( + searchAsset({ asset: "usdc", networkDetails: MAINNET }), + ).rejects.toThrow("Bad Gateway"); + }); +}); 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..5f4fcb9e95 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/swapPopularTokensCache.test.ts @@ -0,0 +1,62 @@ +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(() => jest.clearAllMocks()); + + 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 the background has nothing cached", async () => { + getMock.mockResolvedValue(null); + expect(await getPersistedPopularTokens("PUBLIC")).toBeNull(); + }); + + 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("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(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/__tests__/trendingAssets.test.ts b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts new file mode 100644 index 0000000000..9fe257ab50 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts @@ -0,0 +1,113 @@ +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("propagates the error when the fetch rejects (so the picker can flag discovery-down)", async () => { + jest.spyOn(global, "fetch").mockRejectedValue(new Error("network down")); + await expect( + fetchTrendingAssets({ networkDetails: MAINNET }), + ).rejects.toThrow("network down"); + }); + + it("throws on a non-ok response instead of returning []", async () => { + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + statusText: "Service Unavailable", + json: async () => ({}), + } as unknown as Response); + await expect( + fetchTrendingAssets({ networkDetails: MAINNET }), + ).rejects.toThrow("Service Unavailable"); + }); +}); diff --git a/extension/src/popup/helpers/__tests__/useNetworkFees.test.js b/extension/src/popup/helpers/__tests__/useNetworkFees.test.js index c470cbf187..3b68204fbb 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,43 @@ 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(""); + // Even on failure, the initial fetch settling clears isLoading so callers + // don't stay stuck on a loader. + expect(hookResult.isLoading).toBe(false); + }); + + it("starts loading and clears isLoading after the first fee fetch settles", async () => { + useSelector.mockReturnValue({ + networkUrl: "https://testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }); + + stellarSdkServer.mockReturnValue({ + feeStats: jest.fn().mockResolvedValue({ + max_fee: { mode: "300" }, + ledger_capacity_usage: "0.6", + }), + }); + + const loadingStates = []; + let hookResult; + await act(async () => { + render( + { + hookResult = data; + loadingStates.push(data.isLoading); + }} + />, + ); + }); + + // First render is loading (before feeStats resolves); once the initial + // fetch settles, isLoading is false so the final fee paints once (§ batch4 + // task 8). + expect(loadingStates[0]).toBe(true); + expect(hookResult.isLoading).toBe(false); }); }); 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/__tests__/xlmReserve.test.ts b/extension/src/popup/helpers/__tests__/xlmReserve.test.ts new file mode 100644 index 0000000000..f0c5888ae3 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/xlmReserve.test.ts @@ -0,0 +1,160 @@ +import BigNumber from "bignumber.js"; + +import { + deductNewTrustlineReserve, + pickBestNonXlmClassicCanonical, + shouldShowXlmReservePreflight, +} from "../xlmReserve"; + +const classic = (code: string, issuer: string, total: string) => + ({ + token: { code, issuer: { key: issuer } }, + total: new BigNumber(total), + }) as any; +const native = (total: string) => + ({ + token: { type: "native", code: "XLM" }, + total: new BigNumber(total), + }) as any; +const soroban = (code: string, total: string) => + ({ + contractId: `C${code}`, + token: { code }, + total: new BigNumber(total), + }) as any; + +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); + }); + }); +}); + +describe("deductNewTrustlineReserve", () => { + it("deducts 0.5 XLM when swapping XLM into a new token with spendable >= 0.5", () => { + expect( + deductNewTrustlineReserve({ + spendable: "10", + sourceIsXlm: true, + requiresTrustline: true, + }), + ).toBe("9.5"); + }); + + it("deducts down to zero at exactly the base reserve", () => { + expect( + deductNewTrustlineReserve({ + spendable: "0.5", + sourceIsXlm: true, + requiresTrustline: true, + }), + ).toBe("0"); + }); + + it("leaves spendable untouched below the base reserve (sheet handles it)", () => { + expect( + deductNewTrustlineReserve({ + spendable: "0.4", + sourceIsXlm: true, + requiresTrustline: true, + }), + ).toBe("0.4"); + }); + + it("does not deduct when the destination needs no trustline", () => { + expect( + deductNewTrustlineReserve({ + spendable: "10", + sourceIsXlm: true, + requiresTrustline: false, + }), + ).toBe("10"); + }); + + it("does not deduct when the source is not XLM (reserve comes from XLM balance)", () => { + expect( + deductNewTrustlineReserve({ + spendable: "10", + sourceIsXlm: false, + requiresTrustline: true, + }), + ).toBe("10"); + }); +}); + +describe("pickBestNonXlmClassicCanonical", () => { + it("picks the highest-total non-XLM classic balance, ignoring XLM and Soroban", () => { + const balances = [ + classic("AQUA", "GAQUA", "100"), + classic("USDC", "GUSDC", "250"), + native("500"), + soroban("SRBN", "999"), + ]; + expect(pickBestNonXlmClassicCanonical(balances)).toBe("USDC:GUSDC"); + }); + + it("excludes zero-balance classic tokens", () => { + const balances = [ + classic("ZERO", "GZERO", "0"), + classic("AQUA", "GAQUA", "5"), + ]; + expect(pickBestNonXlmClassicCanonical(balances)).toBe("AQUA:GAQUA"); + }); + + it("returns undefined when only XLM and Soroban tokens are held", () => { + expect( + pickBestNonXlmClassicCanonical([native("500"), soroban("SRBN", "999")]), + ).toBeUndefined(); + }); + + it("returns undefined for an empty balance list", () => { + expect(pickBestNonXlmClassicCanonical([])).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/helpers/blockaid.ts b/extension/src/popup/helpers/blockaid.ts index 13f6bb5c24..f64edc1544 100644 --- a/extension/src/popup/helpers/blockaid.ts +++ b/extension/src/popup/helpers/blockaid.ts @@ -18,7 +18,7 @@ import { overriddenBlockaidResponseSelector, } from "popup/ducks/settings"; import { isDev } from "@shared/helpers/dev"; -import { SecurityLevel } from "popup/constants/blockaid"; +import { BlockaidWarning, SecurityLevel } from "popup/constants/blockaid"; import { fetchJson } from "./fetch"; import { captureFetchError } from "./captureFetchError"; import { Action } from "constants/request"; @@ -443,6 +443,64 @@ 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; +}; + +/** + * Friendly per-feature reasons from a token (asset) Blockaid scan — the same + * descriptions the Add-a-token flow shows. Only Warning/Malicious features are + * surfaced (Benign/Info are trust signals, not reasons). These are carried + * through the swap picker so the review's "Do not proceed" pane can list the + * token's reasons alongside the transaction-scan reasons (§ batch4 task 3). + */ +export const extractAssetScanWarnings = ( + blockaidData?: BlockAidScanAssetResult | null, +): BlockaidWarning[] => { + const features = blockaidData?.features; + if (!features?.length) { + return []; + } + return features + .filter( + (feature) => feature.type === "Warning" || feature.type === "Malicious", + ) + .map((feature) => ({ + description: feature.description, + isError: feature.type === "Malicious", + featureId: feature.feature_id, + })); +}; + export const isTxSuspicious = ( blockaidData?: BlockAidScanTxResult | null, blockaidOverrideState?: string | null, 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("."); diff --git a/extension/src/popup/helpers/getManageAssetXDR.ts b/extension/src/popup/helpers/getManageAssetXDR.ts index c6c758c869..33bc22f733 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"; +export 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); 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]; +}; 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; diff --git a/extension/src/popup/helpers/searchAsset.ts b/extension/src/popup/helpers/searchAsset.ts index 725ac55306..33e33fd8b6 100644 --- a/extension/src/popup/helpers/searchAsset.ts +++ b/extension/src/popup/helpers/searchAsset.ts @@ -22,6 +22,12 @@ export const searchAsset = async ({ `${getApiStellarExpertUrl(networkDetails)}/asset?search=${asset}`, { signal }, ); + // Surface backend outages instead of silently returning a non-records body: + // throwing lets callers (e.g. the swap picker) fall back to held-only with a + // "discovery unavailable" notice rather than rendering an empty result set. + if (!res.ok) { + throw new Error(res.statusText); + } return res.json(); }; diff --git a/extension/src/popup/helpers/swapPopularTokensCache.ts b/extension/src/popup/helpers/swapPopularTokensCache.ts new file mode 100644 index 0000000000..234d1acb07 --- /dev/null +++ b/extension/src/popup/helpers/swapPopularTokensCache.ts @@ -0,0 +1,47 @@ +import { + cacheSwapTopTokens, + getCachedSwapTopTokens, +} from "@shared/api/internal"; +import { POPULAR_TOKENS_STALE_MS } from "popup/ducks/cache"; +import { TrendingAsset } from "popup/helpers/trendingAssets"; + +/** + * Disk-backed (chrome.storage.local) cache of the stellar.expert top-tokens + * (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 = await getCachedSwapTopTokens(network); + if ( + !cached?.tokens?.length || + typeof cached.updatedAt !== "number" || + Date.now() - cached.updatedAt >= POPULAR_TOKENS_STALE_MS + ) { + return null; + } + return cached.tokens as TrendingAsset[]; + } catch (e) { + return null; + } +}; + +export const setPersistedPopularTokens = async ( + network: string, + tokens: TrendingAsset[], +): Promise => { + try { + await cacheSwapTopTokens(network, tokens); + } catch (e) { + // Best-effort: a write failure just means we re-fetch next time. + } +}; diff --git a/extension/src/popup/helpers/trendingAssets.ts b/extension/src/popup/helpers/trendingAssets.ts new file mode 100644 index 0000000000..67f4bf3012 --- /dev/null +++ b/extension/src/popup/helpers/trendingAssets.ts @@ -0,0 +1,69 @@ +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}`; + + // No error swallowing: a backend outage (rejection or non-ok status) must + // propagate so the swap picker can flip to held-only with a "discovery + // unavailable" notice. A successful-but-empty response still returns []. + const res = await fetch(`${base}?${sortParams}`, { signal }); + if (!res.ok) { + throw new Error(res.statusText); + } + 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, + ); +}; diff --git a/extension/src/popup/helpers/useNetworkFees.ts b/extension/src/popup/helpers/useNetworkFees.ts index 4f6eac47b5..3a96023758 100644 --- a/extension/src/popup/helpers/useNetworkFees.ts +++ b/extension/src/popup/helpers/useNetworkFees.ts @@ -33,10 +33,22 @@ 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, ); + // True until the first feeStats request settles. Callers can gate their + // first paint on this so the fee label and the fee-derived XLM available + // balance render their final values once, instead of flashing the seeded + // base-fee placeholder and jumping (§ batch4 task 8). Stays false after the + // first settle — a manual fetchData refresh never re-gates. + const [isLoading, setIsLoading] = useState(true); const fetchData = async () => { try { @@ -55,8 +67,10 @@ export const useNetworkFees = () => { } return { recommendedFee, networkCongestion }; } catch (e) { - setRecommendedFee(BASE_FEE); + setRecommendedFee(stroopToXlm(BASE_FEE).toFixed()); return { recommendedFee }; + } finally { + setIsLoading(false); } }; @@ -67,5 +81,5 @@ export const useNetworkFees = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [networkUrl, networkPassphrase]); - return { recommendedFee, networkCongestion, fetchData }; + return { recommendedFee, networkCongestion, fetchData, isLoading }; }; 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/helpers/xlmReserve.ts b/extension/src/popup/helpers/xlmReserve.ts new file mode 100644 index 0000000000..2bbe05d385 --- /dev/null +++ b/extension/src/popup/helpers/xlmReserve.ts @@ -0,0 +1,84 @@ +import BigNumber from "bignumber.js"; + +import { BASE_RESERVE } from "@shared/constants/stellar"; +import { AssetType } from "@shared/api/types/account-balance"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { + isClassicBalance, + isNativeBalance, + isSorobanBalance, +} from "popup/helpers/balance"; + +// 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); +}; + +// Swapping XLM → a new token locks BASE_RESERVE (0.5 XLM) for the new +// trustline's subentry, so that XLM is not actually spendable. Reserve it +// up-front by deducting it from the source spendable shown on the amount +// screen (so the percentage buttons + insufficient-balance check exclude it). +// Only when there's at least 0.5 XLM spendable to begin with — below that we +// leave the value untouched and let shouldShowXlmReservePreflight surface the +// shortfall through the XlmReserveSheet instead. Mirrors mobile's +// SwapAmountScreen spendable deduction (§2.2). +export const deductNewTrustlineReserve = ({ + spendable, + sourceIsXlm, + requiresTrustline, +}: { + spendable: string; + sourceIsXlm: boolean; + requiresTrustline: boolean; +}): string => { + const base = new BigNumber(spendable); + const reserve = new BigNumber(BASE_RESERVE); + if (sourceIsXlm && requiresTrustline && base.gte(reserve)) { + return base.minus(reserve).toFixed(); + } + return base.toFixed(); +}; + +// Picks the held non-XLM CLASSIC balance with the largest total — the default +// sell token for the "Swap for 0.5 XLM" reserve-recovery affordance on the +// XlmReserveSheet. Returns its canonical ("CODE:ISSUER"), or undefined when +// the account holds no swappable classic token (so the affordance is hidden). +// Mirrors mobile's bestNonXlmClassicBalance, which sorts by fiat then total; +// we sort by total, the value the amount screen already has on hand (§3.2). +export const pickBestNonXlmClassicCanonical = ( + balances: AssetType[], +): string | undefined => { + const best = balances + .filter( + (b) => + isClassicBalance(b) && + !isNativeBalance(b) && + !isSorobanBalance(b) && + new BigNumber(b.total).gt(0), + ) + .sort( + (a, b) => new BigNumber(b.total).comparedTo(new BigNumber(a.total)) ?? 0, + )[0]; + + if (!best || !isClassicBalance(best)) { + return undefined; + } + return getCanonicalFromAsset(best.token.code, best.token.issuer.key); +}; 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..a89c1d40af --- /dev/null +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -0,0 +1,38 @@ +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}}", + "Select a token", + "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.", + "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.", + "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", () => { + 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/en/translation.json b/extension/src/popup/locales/en/translation.json index 52339ec4bb..6b96ff2dfd 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -2,9 +2,12 @@ "{{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", + "About unverified tokens": "About unverified tokens", + "About verified tokens": "About verified tokens", "Account": "Account", "Account details": "Account details", "Account ID": "Account ID", @@ -37,6 +40,8 @@ "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 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", @@ -77,6 +82,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", @@ -144,6 +151,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", @@ -254,6 +262,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", @@ -308,7 +317,9 @@ "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 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", @@ -378,8 +389,10 @@ "Min Amount A": "Min Amount A", "Min Amount B": "Min Amount B", "Min Price": "Min Price", + "Minimum received": "Minimum received", "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", @@ -407,9 +420,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", @@ -455,6 +471,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", @@ -463,6 +480,8 @@ "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", "Receive": "Receive", @@ -498,7 +517,9 @@ "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 a token": "Select a token", "Select an asset": "Select an asset", "Select asset": "Select asset", "Seller": "Seller", @@ -546,6 +567,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", @@ -558,6 +580,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", @@ -571,7 +594,9 @@ "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", "Swap Settings": "Swap Settings", "Swap source": "Swap source", @@ -593,6 +618,12 @@ "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 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.", @@ -600,29 +631,31 @@ "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", - "This asset has been flagged as malicious for the following reasons.": "This asset has been flagged as malicious for the following reasons.", "This asset has been flagged as spam for the following reasons.": "This asset has been flagged as spam for the following reasons.", - "This asset has been flagged as suspicious for the following reasons.": "This asset has been flagged as suspicious for the following reasons.", "This asset has buying liabilities": "This asset has buying liabilities", "This asset is not on your lists": "This asset is not on your lists", "This asset is on your lists": "This asset is on your lists", - "This asset was flagged as malicious": "This asset was flagged as malicious", - "This asset was flagged as malicious (override active)": "This asset was flagged as malicious (override active)", "This asset was flagged as spam": "This asset was flagged as spam", - "This asset was flagged as suspicious": "This asset was flagged as suspicious", - "This asset was flagged as suspicious (override active)": "This asset was flagged as suspicious (override active)", "This authorization is for {{address}}.": "This authorization is for {{address}}.", "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", "This site was flagged as malicious": "This site was flagged as malicious", + "This token does not appear safe for the following reasons.": "This token does not appear safe for the following reasons.", "This token does not support muxed address (M-) as a target destination.": "This token does not support muxed address (M-) as a target destination.", + "This token has been flagged as malicious for the following reasons.": "This token has been flagged as malicious for the following reasons.", + "This token has been flagged as suspicious for the following reasons.": "This token has been flagged as suspicious for the following reasons.", + "This token was flagged as malicious": "This token was flagged as malicious", + "This token was flagged as malicious (override active)": "This token was flagged as malicious (override active)", + "This token was flagged as suspicious": "This token was flagged as suspicious", + "This token was flagged as suspicious (override active)": "This token was flagged as suspicious (override active)", "This transaction could not be completed.": "This transaction could not be completed.", "This transaction does not appear safe for the following reasons": "This transaction does not appear safe for the following reasons", "This transaction does not appear safe for the following reasons.": "This transaction does not appear safe for the following reasons.", @@ -632,14 +665,19 @@ "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 {{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.", "Token ID": "Token ID", "Token ID cannot contain spaces": "Token ID cannot contain spaces", "Token ID is required": "Token ID is required", @@ -672,7 +710,6 @@ "Unable to migrate": "Unable to migrate", "Unable to parse assets lists": "Unable to parse assets lists", "Unable to Scan": "Unable to Scan", - "Unable to scan asset": "Unable to scan asset", "Unable to scan destination token": "Unable to scan destination token", "Unable to scan site": "Unable to scan site", "Unable to scan site for malicious behavior": "Unable to scan site for malicious behavior", @@ -683,6 +720,9 @@ "Unknown error occured": "Unknown error occured", "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.", "Use default account": "Use default account", @@ -694,6 +734,9 @@ "Validate addresses that require a memo": "Validate addresses that require a memo", "Value": "Value", "Verification with": "Verification with", + "Verified": "Verified", + "Verified token": "Verified token", + "Verified tokens": "Verified tokens", "Version": "Version", "View": "View", "View maintenance details": "View maintenance details", @@ -723,6 +766,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", @@ -737,14 +781,16 @@ "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.", "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 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.", @@ -767,6 +813,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 db2c569c1c..60ea76a7cf 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -2,9 +2,12 @@ "{{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", + "About unverified tokens": "About unverified tokens", + "About verified tokens": "About verified tokens", "Account": "Conta", "Account details": "Detalhes da conta", "Account ID": "ID da Conta", @@ -37,6 +40,8 @@ "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 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", @@ -77,6 +82,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", @@ -144,6 +151,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", @@ -254,6 +262,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", @@ -308,7 +317,9 @@ "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 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", @@ -378,8 +389,10 @@ "Min Amount A": "Quantia Mínima A", "Min Amount B": "Quantia Mínima B", "Min Price": "Preço Mínimo", + "Minimum received": "Mínimo recebido", "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", @@ -407,9 +420,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", @@ -455,6 +471,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", @@ -463,6 +480,8 @@ "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": "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", "Receive": "Receber", @@ -498,7 +517,9 @@ "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 a token": "Selecionar um token", "Select an asset": "Selecionar um ativo", "Select asset": "Selecionar ativo", "Seller": "Vendedor", @@ -546,6 +567,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.": "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", @@ -558,6 +580,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", @@ -571,7 +594,9 @@ "Swap": "Trocar", "Swap destination": "Destino da troca", "Swap destination token logo": "Logotipo do token de destino da troca", + "Swap direction": "Direção 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", @@ -593,6 +618,12 @@ "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 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.", @@ -600,29 +631,31 @@ "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", - "This asset has been flagged as malicious for the following reasons.": "Este ativo foi marcado como malicioso pelos seguintes motivos.", "This asset has been flagged as spam for the following reasons.": "Este ativo foi marcado como spam pelos seguintes motivos.", - "This asset has been flagged as suspicious for the following reasons.": "Este ativo foi marcado como suspeito pelos seguintes motivos.", "This asset has buying liabilities": "Este ativo tem passivos de compra", "This asset is not on your lists": "Este ativo não está em suas listas", "This asset is on your lists": "Este ativo está em suas listas", - "This asset was flagged as malicious": "Este ativo foi marcado como malicioso", - "This asset was flagged as malicious (override active)": "Este ativo foi sinalizado como malicioso (substituição ativa)", "This asset was flagged as spam": "Este ativo foi marcado como spam", - "This asset was flagged as suspicious": "Este ativo foi marcado como suspeito", - "This asset was flagged as suspicious (override active)": "Este ativo foi sinalizado como suspeito (substituição ativa)", "This authorization is for {{address}}.": "Esta autorização é para {{address}}.", "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", "This site was flagged as malicious": "Este site foi marcado como malicioso", + "This token does not appear safe for the following reasons.": "Este token não parece seguro pelos seguintes motivos.", "This token does not support muxed address (M-) as a target destination.": "Este token não suporta endereço muxed (M-) como destino.", + "This token has been flagged as malicious for the following reasons.": "Este token foi marcado como malicioso pelos seguintes motivos.", + "This token has been flagged as suspicious for the following reasons.": "Este token foi marcado como suspeito pelos seguintes motivos.", + "This token was flagged as malicious": "Este token foi marcado como malicioso", + "This token was flagged as malicious (override active)": "Este token foi sinalizado como malicioso (substituição ativa)", + "This token was flagged as suspicious": "Este token foi marcado como suspeito", + "This token was flagged as suspicious (override active)": "Este token foi sinalizado como suspeito (substituição ativa)", "This transaction could not be completed.": "Esta transação não pôde ser concluída.", "This transaction does not appear safe for the following reasons": "Esta transação não parece segura pelos seguintes motivos", "This transaction does not appear safe for the following reasons.": "Esta transação não parece segura pelos seguintes motivos.", @@ -632,14 +665,19 @@ "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 {{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.", "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", @@ -672,7 +710,6 @@ "Unable to migrate": "Não foi possível migrar", "Unable to parse assets lists": "Não foi possível fazer parse das asset lists", "Unable to Scan": "Não foi possível verificar", - "Unable to scan asset": "Não foi possível verificar o ativo", "Unable to scan destination token": "Não foi possível verificar o token de destino", "Unable to scan site": "Não foi possível verificar o site", "Unable to scan site for malicious behavior": "Não foi possível escanear o site para comportamento malicioso", @@ -683,6 +720,9 @@ "Unknown error occured": "Erro desconhecido ocorreu", "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.", "Use default account": "Usar conta padrão", @@ -694,6 +734,9 @@ "Validate addresses that require a memo": "Validar endereços que requerem memo", "Value": "Valor", "Verification with": "Verificação com", + "Verified": "Verified", + "Verified token": "Token verificado", + "Verified tokens": "Verified tokens", "Version": "Versão", "View": "Ver", "View maintenance details": "Ver detalhes da manutenção", @@ -721,6 +764,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", @@ -735,14 +779,16 @@ "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.", "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 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.", @@ -765,6 +811,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." } 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); 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; 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/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 = 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)}
); })} diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index 8a01c45d12..743182e6c0 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -1,22 +1,24 @@ import React, { useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate, useLocation } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - +import { ActionStatus } from "@shared/api/types"; import { STEPS } from "popup/constants/swap"; import { emitMetric } from "helpers/metrics"; import { InputType } from "helpers/transaction"; import { TransactionConfirm } from "popup/components/InternalTransaction/SubmitTransaction"; import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { getQuoteExpiredOperationCodes } from "popup/helpers/quoteExpiry"; import { SwapAsset } from "popup/components/swap/SwapAsset"; import { SwapAmount } from "popup/components/swap/SwapAmount"; import { AppDispatch } from "popup/App"; import { resetSubmission, + resetSubmitStatus, saveAmount, saveAmountUsd, saveAsset, saveDestinationAsset, + saveDestinationTokenDetails, saveIsToken, transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; @@ -34,7 +36,6 @@ const SWAP_METRIC_BY_STEP: Partial> = { }; export const Swap = () => { - const { t } = useTranslation(); const dispatch = useDispatch(); const navigate = useNavigate(); const location = useLocation(); @@ -55,6 +56,32 @@ export const Swap = () => { const submission = useSelector(transactionSubmissionSelector); const { transactionSimulation, transactionData } = submission; + // Quote expired at submit (op_under_dest_min / op_too_few_offers): recover to + // the review screen with a fresh quote instead of dead-ending in SubmitFail. + // The live-quote effect on SwapAmount re-fetches the path on remount, and the + // notification is surfaced from the isSwapQuoteExpired flag (§2.1/§3.3). + const isQuoteExpiredAtSubmit = + submission.submitStatus === ActionStatus.ERROR && + submission.isSwapQuoteExpired; + useEffect(() => { + if (!isQuoteExpiredAtSubmit) { + return; + } + emitMetric(METRIC_NAMES.swapQuoteExpired, { + sourceToken: transactionData.asset, + destToken: transactionData.destinationAsset, + sourceAmount: transactionData.amount, + destAmount: transactionData.destinationAmount, + allowedSlippage: transactionData.allowedSlippage, + resultCode: getQuoteExpiredOperationCodes(submission.error).join(", "), + }); + // Clear only the ERROR status (keep the transaction data + the + // isSwapQuoteExpired flag, which drives the amount-screen notification). + dispatch(resetSubmitStatus()); + setActiveStep(STEPS.AMOUNT); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isQuoteExpiredAtSubmit]); + const [inputType, setInputType] = useState("crypto"); useEffect(() => { @@ -96,6 +123,11 @@ export const Swap = () => { const renderStep = (step: STEPS) => { switch (step) { case STEPS.SWAP_CONFIRM: { + // The recovery effect transitions back to review on a quote-expiry + // submit failure; render nothing this frame so SubmitFail never flashes. + if (isQuoteExpiredAtSubmit) { + return null; + } return ( { case STEPS.SET_DST_ASSET: { return ( setActiveStep(STEPS.AMOUNT)} - onClickAsset={(canonical: string, isContract: boolean) => { + onClickAsset={(canonical, isContract, details) => { 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, + requiresTrustline: details?.requiresTrustline, + source: details?.source, + }); setActiveStep(STEPS.AMOUNT); }} /> @@ -149,7 +195,7 @@ export const Swap = () => { default: { return ( setActiveStep(STEPS.AMOUNT)} onClickAsset={(canonical: string, isContract: boolean) => { @@ -157,6 +203,17 @@ 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, + source: "balances", + }); setActiveStep(STEPS.AMOUNT); }} /> diff --git a/extension/src/popup/views/__tests__/ManageAssets.test.tsx b/extension/src/popup/views/__tests__/ManageAssets.test.tsx index 316f59be66..695412eb52 100644 --- a/extension/src/popup/views/__tests__/ManageAssets.test.tsx +++ b/extension/src/popup/views/__tests__/ManageAssets.test.tsx @@ -127,6 +127,7 @@ jest.spyOn(UseNetworkFees, "useNetworkFees").mockImplementation(() => ({ recommendedFee: "0.00001", networkCongestion: UseNetworkFees.NetworkCongestion.MEDIUM, fetchData: () => Promise.resolve({ recommendedFee: "00.1" }), + isLoading: false, })); jest.spyOn(SearchAsset, "searchAsset").mockImplementation(({ asset }) => { diff --git a/extension/src/popup/views/__tests__/Send.test.tsx b/extension/src/popup/views/__tests__/Send.test.tsx index e774836d46..4be22dc09b 100644 --- a/extension/src/popup/views/__tests__/Send.test.tsx +++ b/extension/src/popup/views/__tests__/Send.test.tsx @@ -92,6 +92,7 @@ jest.spyOn(UseNetworkFees, "useNetworkFees").mockImplementation(() => { recommendedFee: ".00001", networkCongestion: UseNetworkFees.NetworkCongestion.MEDIUM, fetchData: () => Promise.resolve({ recommendedFee: "00.1" }), + isLoading: false, }; }); 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..be47e9b1a5 --- /dev/null +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -0,0 +1,366 @@ +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + 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 { 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 = {}, + routes: string[] = ["/swap"], +) => + 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 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 () => { + 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 () => { + 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, + balances: { balances: [usdcBalance], icons: {} }, + filteredBalances: [usdcBalance], + }, + }, + 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 "Your tokens" list renders a clickable USDC row. + const usdcRow = await screen.findByTestId("SwapTokenRow-USDC"); + 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", + }); + }); + + 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(); + }); +}); diff --git a/extension/src/popup/views/__tests__/Swap.test.tsx b/extension/src/popup/views/__tests__/Swap.test.tsx index 26655a2526..8078ff17d2 100644 --- a/extension/src/popup/views/__tests__/Swap.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.test.tsx @@ -128,6 +128,7 @@ jest.spyOn(UseNetworkFees, "useNetworkFees").mockImplementation(() => ({ recommendedFee: "0.00001", networkCongestion: UseNetworkFees.NetworkCongestion.MEDIUM, fetchData: () => Promise.resolve({ recommendedFee: "00.1" }), + isLoading: false, })); jest.spyOn(BlockaidHelpers, "useScanTx").mockImplementation(() => { @@ -976,8 +977,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 () => { diff --git a/extension/src/popup/views/__tests__/SwapUnfunded.test.tsx b/extension/src/popup/views/__tests__/SwapUnfunded.test.tsx index 0161d64dbd..9313a484ee 100644 --- a/extension/src/popup/views/__tests__/SwapUnfunded.test.tsx +++ b/extension/src/popup/views/__tests__/SwapUnfunded.test.tsx @@ -34,6 +34,7 @@ jest.spyOn(UseNetworkFees, "useNetworkFees").mockImplementation(() => ({ recommendedFee: "0.00001", networkCongestion: UseNetworkFees.NetworkCongestion.MEDIUM, fetchData: () => Promise.resolve({ recommendedFee: "00.1" }), + isLoading: false, })); jest.mock("popup/helpers/horizonGetBestPath", () => ({ diff --git a/extension/webpack.extension.js b/extension/webpack.extension.js index 43efc63aec..dd48dafc29 100644 --- a/extension/webpack.extension.js +++ b/extension/webpack.extension.js @@ -87,7 +87,12 @@ const prodConfig = ( keepRemoved: true, removeUnusedKeys: false, keySeparator: false, + // i18next-parser (wrapped by i18next-scanner-webpack) uses + // `namespaceSeparator`, not `nsSeparator`. Without this, a key + // containing ": " (e.g. "Maximum spendable: {{amount}}") is + // split into a stray namespace file. Keep both names set. nsSeparator: false, + namespaceSeparator: false, func: { list: ["t", "i18next.t", "i18n.t"], extensions: [".ts", ".tsx"], 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;