From 0a5a9ad94eccc6e0af6ec0c4b652a34f9874e927 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 4 Nov 2026 18:41:33 -0500 Subject: [PATCH 01/15] feat(swap): add backend-v2 swap quote client getSwapQuotes calls POST /swap/quotes through the existing FETCH_BACKEND_V2 chokepoint (JWT-signed in the service worker). 400 maps to a bad-input error, every other non-200 to "unavailable". getSwapQuote uses it on standard networks and keeps Horizon path finding on custom networks, which the backend can't reach. Until the backend builds the transaction (C2), the classic route is used; pickSwapRoute is the one place to switch that to routes[0]. Co-Authored-By: Claude Opus 5.5 --- .../helpers/__tests__/getSwapQuotes.test.ts | 104 +++++++ @shared/api/helpers/getSwapQuotes.ts | 52 ++++ @shared/api/types/backend-api.ts | 40 +++ .../popup/helpers/__tests__/swapQuote.test.ts | 269 ++++++++++++++++++ extension/src/popup/helpers/swapQuote.ts | 129 +++++++++ 5 files changed, 594 insertions(+) create mode 100644 @shared/api/helpers/__tests__/getSwapQuotes.test.ts create mode 100644 @shared/api/helpers/getSwapQuotes.ts create mode 100644 extension/src/popup/helpers/__tests__/swapQuote.test.ts create mode 100644 extension/src/popup/helpers/swapQuote.ts diff --git a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts new file mode 100644 index 0000000000..9a24ea2042 --- /dev/null +++ b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts @@ -0,0 +1,104 @@ +import { getSwapQuotes, SwapQuotesError } from "../getSwapQuotes"; +import { fetchBackendV2 } from "../fetchBackendV2"; + +jest.mock("../fetchBackendV2"); +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); +const mockedFetch = fetchBackendV2 as jest.Mock; + +const ACCOUNT = "GCXRLIZUQNZ3YYJDGX6Z445P7FG5WXT7UILBO5CFIYYM7Z7YTIOELC6O"; +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +const request = { + network: "PUBLIC", + account: ACCOUNT, + mode: "send" as const, + fromAsset: "XLM", + toAsset: USDC, + amount: "100", +}; + +const quotes = { + routes: [ + { + source: "classic", + fromAmount: "100", + toAmount: "17.5", + paths: [ + { + share: "1", + hops: [{ venue: "stellar-dex", from: "XLM", to: USDC }], + }, + ], + fees: [], + }, + ], + unavailable: [], +}; + +describe("getSwapQuotes", () => { + afterEach(() => jest.clearAllMocks()); + + it("POSTs the quote request to /swap/quotes with the network in the query", async () => { + mockedFetch.mockResolvedValue({ status: 200, body: { data: quotes } }); + + await getSwapQuotes(request); + + expect(mockedFetch).toHaveBeenCalledWith({ + method: "POST", + path: "/swap/quotes?network=PUBLIC", + body: JSON.stringify({ + account: ACCOUNT, + mode: "send", + fromAsset: "XLM", + toAsset: USDC, + amount: "100", + }), + }); + }); + + it("returns the unwrapped response on 200", async () => { + mockedFetch.mockResolvedValue({ status: 200, body: { data: quotes } }); + + await expect(getSwapQuotes(request)).resolves.toEqual(quotes); + }); + + it("returns empty routes on 200 with no route", async () => { + const empty = { + routes: [], + unavailable: [{ source: "classic", reason: "no_route" }], + }; + mockedFetch.mockResolvedValue({ status: 200, body: { data: empty } }); + + await expect(getSwapQuotes(request)).resolves.toEqual(empty); + }); + + it("rejects a 400 as bad input", async () => { + mockedFetch.mockResolvedValue({ + status: 400, + body: { error: "invalid amount" }, + }); + + const err = await getSwapQuotes(request).catch((e) => e); + expect(err).toBeInstanceOf(SwapQuotesError); + expect(err.kind).toBe("badRequest"); + }); + + it.each([502, 503, 401, 500])( + "rejects a %s as swaps unavailable", + async (status) => { + mockedFetch.mockResolvedValue({ status, body: { error: "x" } }); + + const err = await getSwapQuotes(request).catch((e) => e); + expect(err).toBeInstanceOf(SwapQuotesError); + expect(err.kind).toBe("unavailable"); + }, + ); + + it("rejects a 200 without a routes list as swaps unavailable", async () => { + mockedFetch.mockResolvedValue({ status: 200, body: { data: {} } }); + + const err = await getSwapQuotes(request).catch((e) => e); + expect(err).toBeInstanceOf(SwapQuotesError); + expect(err.kind).toBe("unavailable"); + }); +}); diff --git a/@shared/api/helpers/getSwapQuotes.ts b/@shared/api/helpers/getSwapQuotes.ts new file mode 100644 index 0000000000..68bb0814b2 --- /dev/null +++ b/@shared/api/helpers/getSwapQuotes.ts @@ -0,0 +1,52 @@ +import { captureException } from "@sentry/browser"; + +import { SwapQuoteMode, SwapQuotesResponse } from "../types/backend-api"; +import { fetchBackendV2 } from "./fetchBackendV2"; +import { redactErrorBody } from "./redactErrorBody"; + +export type SwapQuotesErrorKind = "badRequest" | "unavailable"; + +export class SwapQuotesError extends Error { + kind: SwapQuotesErrorKind; + + constructor(kind: SwapQuotesErrorKind, message: string) { + super(message); + this.name = "SwapQuotesError"; + this.kind = kind; + } +} + +export const getSwapQuotes = async ({ + network, + account, + mode, + fromAsset, + toAsset, + amount, +}: { + network: string; + account: string; + mode: SwapQuoteMode; + fromAsset: string; + toAsset: string; + amount: string; +}): Promise => { + const { status, body } = await fetchBackendV2({ + method: "POST", + path: `/swap/quotes?network=${network}`, + body: JSON.stringify({ account, mode, fromAsset, toAsset, amount }), + }); + + const data = (body as { data?: SwapQuotesResponse } | null)?.data; + if (status === 200 && Array.isArray(data?.routes)) { + return data; + } + + const message = `Failed to fetch swap quotes - ${status}: ${redactErrorBody(body)}`; + // A 400 is the input (e.g. an unfunded account), not an outage. + if (status === 400) { + throw new SwapQuotesError("badRequest", message); + } + captureException(message); + throw new SwapQuotesError("unavailable", message); +}; diff --git a/@shared/api/types/backend-api.ts b/@shared/api/types/backend-api.ts index 6901c08544..48d3593ee4 100644 --- a/@shared/api/types/backend-api.ts +++ b/@shared/api/types/backend-api.ts @@ -150,3 +150,43 @@ export interface V2AccountBalances { subentry_count: number; balances: V2Balance[]; } + +// freighter-backend-v2 `POST /swap/quotes` wire types; mirror +// internal/types/swap.go. Assets are "XLM", "CODE:ISSUER" or a "C…" contract ID. +export type SwapQuoteMode = "send" | "receive"; + +export interface SwapHop { + venue: string; + from: string; + to: string; + pool?: string; +} + +export interface SwapPath { + share: string; + hops: SwapHop[]; +} + +export interface SwapFee { + name: string; + amount: string; + asset: string; +} + +export interface SwapRoute { + source: string; + fromAmount: string; + toAmount: string; + paths: SwapPath[]; + fees: SwapFee[]; +} + +export interface SwapUnavailable { + source: string; + reason: string; +} + +export interface SwapQuotesResponse { + routes: SwapRoute[]; + unavailable: SwapUnavailable[]; +} diff --git a/extension/src/popup/helpers/__tests__/swapQuote.test.ts b/extension/src/popup/helpers/__tests__/swapQuote.test.ts new file mode 100644 index 0000000000..50f46bcb86 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/swapQuote.test.ts @@ -0,0 +1,269 @@ +import { + MAINNET_NETWORK_DETAILS, + NetworkDetails, +} from "@shared/constants/stellar"; +import { CUSTOM_NETWORK } from "@shared/helpers/stellar"; +import * as GetSwapQuotes from "@shared/api/helpers/getSwapQuotes"; +import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; + +import { getSwapQuote } from "../swapQuote"; + +const ACCOUNT = "GCXRLIZUQNZ3YYJDGX6Z445P7FG5WXT7UILBO5CFIYYM7Z7YTIOELC6O"; +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; +const TOKEN_CONTRACT = + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + +const CUSTOM_NETWORK_DETAILS: NetworkDetails = { + network: CUSTOM_NETWORK, + networkName: "Local", + networkUrl: "http://localhost:8000", + networkPassphrase: "Standalone Network ; February 2017", +}; + +const classicRoute = { + source: "classic", + fromAmount: "100", + toAmount: "17.5", + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: "XLM", to: AQUA }, + { venue: "stellar-dex", from: AQUA, to: USDC }, + ], + }, + ], + fees: [], +}; + +const lifiRoute = { + source: "lifi", + fromAmount: "100", + toAmount: "18", + paths: [ + { + share: "1", + hops: [{ venue: "Aquarius", from: "XLM", to: USDC, pool: "abc" }], + }, + ], + fees: [], +}; + +describe("getSwapQuote", () => { + let getSwapQuotes: jest.SpyInstance; + let horizonSend: jest.SpyInstance; + let horizonReceive: jest.SpyInstance; + + beforeEach(() => { + getSwapQuotes = jest.spyOn(GetSwapQuotes, "getSwapQuotes"); + horizonSend = jest.spyOn(HorizonGetBestPath, "horizonGetBestPath"); + horizonReceive = jest.spyOn( + HorizonGetBestPath, + "horizonGetBestReceivePath", + ); + }); + afterEach(() => jest.restoreAllMocks()); + + describe("on a standard network", () => { + it("asks the backend, not Horizon, with backend asset ids", async () => { + getSwapQuotes.mockResolvedValue({ routes: [], unavailable: [] }); + + await getSwapQuote({ + mode: "send", + amount: "1,000.5", + sourceAsset: "native", + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(getSwapQuotes).toHaveBeenCalledWith({ + network: "PUBLIC", + account: ACCOUNT, + mode: "send", + fromAsset: "XLM", + toAsset: USDC, + amount: "1000.5", + }); + expect(horizonSend).not.toHaveBeenCalled(); + expect(horizonReceive).not.toHaveBeenCalled(); + }); + + it("sends a contract token as its contract ID", async () => { + getSwapQuotes.mockResolvedValue({ routes: [], unavailable: [] }); + + await getSwapQuote({ + mode: "receive", + amount: "0.5", + sourceAsset: `TKN:${TOKEN_CONTRACT}`, + destAsset: "native", + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(getSwapQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "receive", + fromAsset: TOKEN_CONTRACT, + toAsset: "XLM", + }), + ); + }); + + it("uses the classic route, even when it is not the best, and keeps the full response", async () => { + const quotes = { routes: [lifiRoute, classicRoute], unavailable: [] }; + getSwapQuotes.mockResolvedValue(quotes); + + const result = await getSwapQuote({ + mode: "send", + amount: "100", + sourceAsset: "native", + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(result).toEqual({ + route: { + sourceAmount: "100", + destinationAmount: "17.5", + path: [AQUA], + }, + quotes, + }); + }); + + it("maps an XLM intermediate hop to the canonical native id", async () => { + const route = { + ...classicRoute, + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: AQUA, to: "XLM" }, + { venue: "stellar-dex", from: "XLM", to: USDC }, + ], + }, + ], + }; + getSwapQuotes.mockResolvedValue({ routes: [route], unavailable: [] }); + + const result = await getSwapQuote({ + mode: "send", + amount: "100", + sourceAsset: AQUA, + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(result.route?.path).toEqual(["native"]); + }); + + it("has no route when no classic route came back", async () => { + const quotes = { + routes: [lifiRoute], + unavailable: [{ source: "classic", reason: "no_route" }], + }; + getSwapQuotes.mockResolvedValue(quotes); + + const result = await getSwapQuote({ + mode: "send", + amount: "100", + sourceAsset: "native", + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(result).toEqual({ route: null, quotes }); + }); + }); + + describe("on a custom network", () => { + it("keeps Horizon strict-send path finding", async () => { + horizonSend.mockResolvedValue({ + source_amount: "100", + destination_amount: "17.5", + path: [ + { + asset_type: "credit_alphanum4", + asset_code: "AQUA", + asset_issuer: AQUA.split(":")[1], + }, + { asset_type: "native" }, + ], + } as any); + + const result = await getSwapQuote({ + mode: "send", + amount: "100", + sourceAsset: "native", + destAsset: USDC, + networkDetails: CUSTOM_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(getSwapQuotes).not.toHaveBeenCalled(); + expect(horizonSend).toHaveBeenCalledWith({ + amount: "100", + sourceAsset: "native", + destAsset: USDC, + networkDetails: CUSTOM_NETWORK_DETAILS, + }); + expect(result).toEqual({ + route: { + sourceAmount: "100", + destinationAmount: "17.5", + path: [AQUA, "native"], + }, + quotes: null, + }); + }); + + it("keeps Horizon strict-receive path finding for receive quotes", async () => { + horizonReceive.mockResolvedValue({ + source_amount: "3.2", + destination_amount: "0.5", + path: [], + } as any); + + const result = await getSwapQuote({ + mode: "receive", + amount: "0.5", + sourceAsset: USDC, + destAsset: "native", + networkDetails: CUSTOM_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(horizonReceive).toHaveBeenCalledWith({ + destinationAmount: "0.5", + sourceAsset: USDC, + destAsset: "native", + networkDetails: CUSTOM_NETWORK_DETAILS, + }); + expect(result.route).toEqual({ + sourceAmount: "3.2", + destinationAmount: "0.5", + path: [], + }); + }); + + it("has no route when Horizon finds no path", async () => { + horizonSend.mockResolvedValue(undefined as any); + + const result = await getSwapQuote({ + mode: "send", + amount: "100", + sourceAsset: "native", + destAsset: USDC, + networkDetails: CUSTOM_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(result).toEqual({ route: null, quotes: null }); + }); + }); +}); diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts new file mode 100644 index 0000000000..bc065baabc --- /dev/null +++ b/extension/src/popup/helpers/swapQuote.ts @@ -0,0 +1,129 @@ +import { Horizon, StrKey } from "stellar-sdk"; + +import { getSwapQuotes } from "@shared/api/helpers/getSwapQuotes"; +import { + SwapQuoteMode, + SwapQuotesResponse, + SwapRoute, +} from "@shared/api/types/backend-api"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { isNativeAssetId } from "@shared/helpers/assetIdentity"; +import { + getCanonicalFromAsset, + isCustomNetwork, +} from "@shared/helpers/stellar"; +import { cleanAmount } from "popup/helpers/formatters"; +import { + horizonGetBestPath, + horizonGetBestReceivePath, +} from "popup/helpers/horizonGetBestPath"; + +export interface SwapQuoteRoute { + sourceAmount: string; + destinationAmount: string; + /** Intermediate assets, canonical form, for the path payment. */ + path: string[]; +} + +export interface SwapQuoteResult { + /** null = no route. */ + route: SwapQuoteRoute | null; + /** The full backend response; null on custom networks (Horizon). */ + quotes: SwapQuotesResponse | null; +} + +// The client still builds a classic path payment itself, so only the classic +// route is usable, even when another source quotes better. +export const pickSwapRoute = (quotes: SwapQuotesResponse) => + quotes.routes.find((route) => route.source === "classic"); + +const toBackendAsset = (canonical: string) => { + if (isNativeAssetId(canonical)) { + return "XLM"; + } + const issuer = canonical.split(":")[1]; + return issuer && StrKey.isValidContract(issuer) ? issuer : canonical; +}; + +const fromBackendAsset = (asset: string) => { + const [code, issuer] = asset.split(":"); + return getCanonicalFromAsset(code, issuer); +}; + +const routeFromBackend = (route: SwapRoute): SwapQuoteRoute | null => { + const hops = route.paths[0]?.hops; + if (!hops?.length) { + return null; + } + return { + sourceAmount: route.fromAmount, + destinationAmount: route.toAmount, + path: hops.slice(1).map((hop) => fromBackendAsset(hop.from)), + }; +}; + +const routeFromHorizon = ( + record?: Horizon.ServerApi.PaymentPathRecord, +): SwapQuoteRoute | null => { + if (!record?.destination_amount) { + return null; + } + return { + sourceAmount: record.source_amount, + destinationAmount: record.destination_amount, + path: record.path.map((p) => + !p.asset_code && !p.asset_issuer + ? p.asset_type + : getCanonicalFromAsset(p.asset_code, p.asset_issuer), + ), + }; +}; + +/** + * `amount` is sold in "send" mode and received in "receive" mode. Custom + * networks use Horizon directly because the backend can't reach them. + */ +export const getSwapQuote = async ({ + mode, + amount, + sourceAsset, + destAsset, + networkDetails, + publicKey, +}: { + mode: SwapQuoteMode; + amount: string; + sourceAsset: string; + destAsset: string; + networkDetails: NetworkDetails; + publicKey: string; +}): Promise => { + if (isCustomNetwork(networkDetails)) { + const record = + mode === "send" + ? await horizonGetBestPath({ + amount, + sourceAsset, + destAsset, + networkDetails, + }) + : await horizonGetBestReceivePath({ + destinationAmount: amount, + sourceAsset, + destAsset, + networkDetails, + }); + return { route: routeFromHorizon(record), quotes: null }; + } + + const quotes = await getSwapQuotes({ + network: networkDetails.network, + account: publicKey, + mode, + fromAsset: toBackendAsset(sourceAsset), + toAsset: toBackendAsset(destAsset), + amount: cleanAmount(amount), + }); + const route = pickSwapRoute(quotes); + return { route: route ? routeFromBackend(route) : null, quotes }; +}; From bad1536c93c47f617eeb117266a01ef359513c6f Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 4 Nov 2026 18:41:34 -0500 Subject: [PATCH 02/15] feat(swap): quote from the backend in the live quote and reserve sheet The debounced live quote, the review-time fallback quote and the XLM reserve sheet's receive quote now go through getSwapQuote. The full response is kept in transactionData.swapQuotes for C2/C4. A 502/503 (or any non-400 failure) shows "Swaps are temporarily unavailable"; empty routes, no classic route, or a 400 show "No quote available". Unit tests mock fetchBackendV2 instead of Horizon. e2e mocks move from page.route("**/paths**") to a context-level stubSwapQuotes, since the request leaves from the service worker; stubAllExternalApis now answers /swap/quotes with no route by default, and the two swap tests that relied on live Horizon now stub a quote. Co-Authored-By: Claude Opus 5.5 --- .../e2e-tests/blockaidScan.malicious.test.ts | 29 +-- extension/e2e-tests/blockaidScan.safe.test.ts | 29 +-- .../e2e-tests/blockaidScan.suspicious.test.ts | 29 +-- .../e2e-tests/blockaidScan.unable.test.ts | 30 +--- extension/e2e-tests/helpers/stubs.ts | 47 +++++ extension/e2e-tests/swap.test.ts | 112 ++---------- .../SwapAmount.directionToggle.test.tsx | 9 +- .../__tests__/SwapAmount.fiatLabel.test.tsx | 9 +- .../__tests__/SwapAmount.liveQuote.test.tsx | 163 ++++++++++++++--- .../SwapAmount.reserveQuote.test.tsx | 165 ++++++++++++++++++ .../helpers/__tests__/swapCtaState.test.ts | 14 ++ .../SwapAmount/helpers/getSwapDerivedData.ts | 3 + .../swap/SwapAmount/helpers/swapCtaState.ts | 6 + .../useSimulateSwapData.quote.test.tsx | 161 +++++++++++++++++ .../SwapAmount/hooks/useSimulateSwapData.tsx | 50 +++--- .../swap/SwapAmount/hooks/useSwapLiveQuote.ts | 57 +++--- .../components/swap/SwapAmount/index.tsx | 17 +- .../src/popup/ducks/transactionSubmission.ts | 15 +- .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + 20 files changed, 656 insertions(+), 291 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx create mode 100644 extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx diff --git a/extension/e2e-tests/blockaidScan.malicious.test.ts b/extension/e2e-tests/blockaidScan.malicious.test.ts index fb36bd6991..e0431f1993 100644 --- a/extension/e2e-tests/blockaidScan.malicious.test.ts +++ b/extension/e2e-tests/blockaidScan.malicious.test.ts @@ -14,9 +14,9 @@ import { stubScanAssetMalicious, stubScanTxMalicious, stubScanDappMalicious, - createAssetObject, stubAssetSearch, stubMemoRequiredAccounts, + stubSwapQuotes, } from "./helpers/stubs"; import { testBlockaidFeedback } from "./helpers/blockaid"; import { goToAddAsset } from "./helpers/assets"; @@ -310,32 +310,7 @@ test.describe("BlockAid Scan - Malicious States", () => { }); await stubAccountBalancesV2(page, balancesJson); - await page.route("**/paths**", async (route) => { - const url = new URL(route.request().url()); - const sourceAsset = url.searchParams.get("source_asset_code"); - const destAsset = url.searchParams.get("destination_asset_code"); - - const source = createAssetObject(sourceAsset, USDC_ISSUER); - const destination = createAssetObject(destAsset, USDC_ISSUER); - - const json = { - _embedded: { - records: [ - { - source_asset_type: source.asset_type, - source_asset_code: source.asset_code, - source_asset_issuer: source.asset_issuer, - destination_asset_type: destination.asset_type, - destination_asset_code: destination.asset_code, - destination_asset_issuer: destination.asset_issuer, - destination_amount: "0.95", - path: [], - }, - ], - }, - }; - await route.fulfill({ json }); - }); + await stubSwapQuotes(context, { toAmount: "0.95" }); await stubAccountHistory(page); await stubTokenDetails(page); diff --git a/extension/e2e-tests/blockaidScan.safe.test.ts b/extension/e2e-tests/blockaidScan.safe.test.ts index 79fce8f40d..b27771da96 100644 --- a/extension/e2e-tests/blockaidScan.safe.test.ts +++ b/extension/e2e-tests/blockaidScan.safe.test.ts @@ -12,8 +12,8 @@ import { stubTokenPrices, stubScanAssetSafe, stubScanTxSafe, - createAssetObject, stubAssetSearch, + stubSwapQuotes, } from "./helpers/stubs"; import { goToAddAsset } from "./helpers/assets"; @@ -226,32 +226,7 @@ test.describe("BlockAid Scan - Safe States (No Override)", () => { }); await stubAccountBalancesV2(page, balancesJson); - await page.route("**/paths**", async (route) => { - const url = new URL(route.request().url()); - const sourceAsset = url.searchParams.get("source_asset_code"); - const destAsset = url.searchParams.get("destination_asset_code"); - - const source = createAssetObject(sourceAsset, USDC_ISSUER); - const destination = createAssetObject(destAsset, USDC_ISSUER); - - const json = { - _embedded: { - records: [ - { - source_asset_type: source.asset_type, - source_asset_code: source.asset_code, - source_asset_issuer: source.asset_issuer, - destination_asset_type: destination.asset_type, - destination_asset_code: destination.asset_code, - destination_asset_issuer: destination.asset_issuer, - destination_amount: "0.95", - path: [], - }, - ], - }, - }; - await route.fulfill({ json }); - }); + await stubSwapQuotes(context, { toAmount: "0.95" }); await stubAccountHistory(page); await stubTokenDetails(page); diff --git a/extension/e2e-tests/blockaidScan.suspicious.test.ts b/extension/e2e-tests/blockaidScan.suspicious.test.ts index e320778791..4abba3ad22 100644 --- a/extension/e2e-tests/blockaidScan.suspicious.test.ts +++ b/extension/e2e-tests/blockaidScan.suspicious.test.ts @@ -8,9 +8,9 @@ import { stubTokenPrices, stubScanAssetSuspicious, stubScanTxSuspicious, - createAssetObject, stubAssetSearch, stubMemoRequiredAccounts, + stubSwapQuotes, } from "./helpers/stubs"; import { testBlockaidFeedback } from "./helpers/blockaid"; import { goToAddAsset } from "./helpers/assets"; @@ -219,32 +219,7 @@ test.describe("BlockAid Scan - Suspicious States", () => { }); await stubAccountBalancesV2(page, balancesJson); - await page.route("**/paths**", async (route) => { - const url = new URL(route.request().url()); - const sourceAsset = url.searchParams.get("source_asset_code"); - const destAsset = url.searchParams.get("destination_asset_code"); - - const source = createAssetObject(sourceAsset, USDC_ISSUER); - const destination = createAssetObject(destAsset, USDC_ISSUER); - - const json = { - _embedded: { - records: [ - { - source_asset_type: source.asset_type, - source_asset_code: source.asset_code, - source_asset_issuer: source.asset_issuer, - destination_asset_type: destination.asset_type, - destination_asset_code: destination.asset_code, - destination_asset_issuer: destination.asset_issuer, - destination_amount: "0.95", - path: [], - }, - ], - }, - }; - await route.fulfill({ json }); - }); + await stubSwapQuotes(context, { toAmount: "0.95" }); await stubAccountHistory(page); await stubTokenDetails(page); diff --git a/extension/e2e-tests/blockaidScan.unable.test.ts b/extension/e2e-tests/blockaidScan.unable.test.ts index e3c8b9f2bc..ed30e0ab03 100644 --- a/extension/e2e-tests/blockaidScan.unable.test.ts +++ b/extension/e2e-tests/blockaidScan.unable.test.ts @@ -14,8 +14,8 @@ import { stubScanDappUnableToScan, stubTokenDetails, stubTokenPrices, - createAssetObject, stubMemoRequiredAccounts, + stubSwapQuotes, } from "./helpers/stubs"; import { goToAddAsset } from "./helpers/assets"; @@ -240,33 +240,7 @@ test.describe("BlockAid Scan - Unable to Scan States", () => { await stubAccountBalancesV2(page, balancesJson); // Mock swap path finding endpoint (Horizon path payment) - await page.route("**/paths**", async (route) => { - const url = new URL(route.request().url()); - const sourceAsset = url.searchParams.get("source_asset_code"); - const destAsset = url.searchParams.get("destination_asset_code"); - - const source = createAssetObject(sourceAsset, USDC_ISSUER); - const destination = createAssetObject(destAsset, USDC_ISSUER); - - // Return a valid path for swaps - const json = { - _embedded: { - records: [ - { - source_asset_type: source.asset_type, - source_asset_code: source.asset_code, - source_asset_issuer: source.asset_issuer, - destination_asset_type: destination.asset_type, - destination_asset_code: destination.asset_code, - destination_asset_issuer: destination.asset_issuer, - destination_amount: "0.95", - path: [], - }, - ], - }, - }; - await route.fulfill({ json }); - }); + await stubSwapQuotes(context, { toAmount: "0.95" }); // Transaction scan should be unable to scan await stubAccountHistory(page); diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index a072e0eb2c..723404af17 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -1037,6 +1037,51 @@ export const stubTokenStats = async ( }); }; +/** + * freighter-backend-v2 `POST /swap/quotes`. Sent from the background service + * worker, so it must be a context route. Echoes the request's assets back as a + * direct classic route; `toAmount: null` answers with no route. + */ +export const stubSwapQuotes = async ( + context: BrowserContext, + { toAmount }: { toAmount: string | null } = { toAmount: null }, +) => { + await context.route("**/swap/quotes**", async (route) => { + const { fromAsset, toAsset, amount, mode } = + route.request().postDataJSON() ?? {}; + const routes = + toAmount === null + ? [] + : [ + { + source: "classic", + fromAmount: mode === "receive" ? toAmount : amount, + toAmount: mode === "receive" ? amount : toAmount, + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: fromAsset, to: toAsset }, + ], + }, + ], + fees: [], + }, + ]; + await route.fulfill({ + json: { + data: { + routes, + unavailable: + toAmount === null + ? [{ source: "classic", reason: "no_route" }] + : [], + }, + }, + }); + }); +}; + // --------------------------------------------------------------------------- // freighter-backend-v2 `POST /accounts/balances` stub. // @@ -3442,6 +3487,8 @@ export const stubAllExternalApis = async ( // Blockaid scan await stubScanTx(page); await stubScanDapp(context); + + await stubSwapQuotes(context); }; export const stubBackendSettings = async (page: Page) => { diff --git a/extension/e2e-tests/swap.test.ts b/extension/e2e-tests/swap.test.ts index 4fcc8399dc..0ad6cdf449 100644 --- a/extension/e2e-tests/swap.test.ts +++ b/extension/e2e-tests/swap.test.ts @@ -47,6 +47,7 @@ import { stubScanAssetSafe, stubScanTxMalicious, stubScanTxSafe, + stubSwapQuotes, } from "./helpers/stubs"; // Soroban contract address — searching for this should produce the Soroban empty state. const SOROBAN_CONTRACT_ADDRESS = @@ -113,26 +114,7 @@ test("held-to-held swap reaches review screen", async ({ }); await stubAccountBalancesV2(page, balancesJson); // Path so the held-to-held swap simulates and reaches the review. - await page.route("**/paths**", (route) => - route.fulfill({ - json: { - _embedded: { - records: [ - { - source_asset_type: "native", - source_asset_code: "XLM", - source_asset_issuer: "", - destination_asset_type: "credit_alphanum4", - destination_asset_code: "USDC", - destination_asset_issuer: USDC_ISSUER, - destination_amount: "0.95", - path: [], - }, - ], - }, - }, - }), - ); + await stubSwapQuotes(context, { toAmount: "0.95" }); }; await loginToTestAccount({ page, extensionId, context, stubOverrides }); @@ -216,6 +198,8 @@ test("swap to new token shows trustline banner at review", async ({ }; await loginToTestAccount({ page, extensionId, context, stubOverrides }); + // Registered after login so it overrides the default no-route stub. + await stubSwapQuotes(context, { toAmount: "0.95" }); await switchToMainnet(page); await page.getByTestId("nav-link-swap").click(); @@ -320,6 +304,8 @@ test("shows XLM-reserve sheet when balance cannot cover the reserve", async ({ }; await loginToTestAccount({ page, extensionId, context, stubOverrides }); + // Registered after login so it overrides the default no-route stub. + await stubSwapQuotes(context, { toAmount: "0.95" }); await switchToMainnet(page); await page.getByTestId("nav-link-swap").click(); @@ -376,27 +362,7 @@ test("flagged destination surfaces blockaid malicious warning at review", async // Stub a swap path so the simulation succeeds and reaches the review // screen (where the transaction scan runs). Without this the swap dies at // "No path found for swap" and never reaches the banner. - await page.route("**/paths**", (route) => - route.fulfill({ - json: { - _embedded: { - records: [ - { - source_asset_type: "native", - source_asset_code: "XLM", - source_asset_issuer: "", - destination_asset_type: "credit_alphanum4", - destination_asset_code: "SCAM", - destination_asset_issuer: - "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", - destination_amount: "0.95", - path: [], - }, - ], - }, - }, - }), - ); + await stubSwapQuotes(context, { toAmount: "0.95" }); // Stub scan-tx to return Malicious (stubScanTxMalicious matches "**/scan-tx**") await stubScanTxMalicious(page); }; @@ -576,27 +542,7 @@ test("flagged non-held destination stacks trustline and Blockaid banners", async route.fulfill({ json: { _embedded: { records: [] } } }), ); // Path so the simulation succeeds and reaches review. - await page.route("**/paths**", (route) => - route.fulfill({ - json: { - _embedded: { - records: [ - { - source_asset_type: "native", - source_asset_code: "XLM", - source_asset_issuer: "", - destination_asset_type: "credit_alphanum4", - destination_asset_code: "SCAM", - destination_asset_issuer: - "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", - destination_amount: "0.95", - path: [], - }, - ], - }, - }, - }), - ); + await stubSwapQuotes(context, { toAmount: "0.95" }); // Flag the destination TOKEN malicious via the bulk-scan endpoint. The swap // reads data.results["CODE-ISSUER"], so the map MUST be keyed exactly by // "SCAM-". The transaction scan stays safe, so the review's single @@ -710,26 +656,7 @@ test("completes a swap to a new token and shows the success summary", async ({ }), ); // Path so the swap simulates and builds a prepared transaction to submit. - await page.route("**/paths**", (route) => - route.fulfill({ - json: { - _embedded: { - records: [ - { - source_asset_type: "native", - source_asset_code: "XLM", - source_asset_issuer: "", - destination_asset_type: "credit_alphanum4", - destination_asset_code: "AQUA", - destination_asset_issuer: AQUA_ISSUER, - destination_amount: "0.95", - path: [], - }, - ], - }, - }, - }), - ); + await stubSwapQuotes(context, { toAmount: "0.95" }); // Token + transaction both scan clean, so the review shows the normal // Confirm button (no "Confirm anyway"). await page.route("**/scan-asset-bulk**", (route) => @@ -923,26 +850,7 @@ test("recovers to the amount screen when the swap quote expires at submit", asyn await page.route("**/asset?sort=volume7d**", (route) => route.fulfill({ json: { _embedded: { records: [] } } }), ); - await page.route("**/paths**", (route) => - route.fulfill({ - json: { - _embedded: { - records: [ - { - source_asset_type: "native", - source_asset_code: "XLM", - source_asset_issuer: "", - destination_asset_type: "credit_alphanum4", - destination_asset_code: "AQUA", - destination_asset_issuer: AQUA_ISSUER, - destination_amount: "0.95", - path: [], - }, - ], - }, - }, - }), - ); + await stubSwapQuotes(context, { toAmount: "0.95" }); await stubScanTxSafe(page); await stubScanAssetSafe(page); // Submit fails with a quote-expiry op code → the flow recovers to review. 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 index 6a259dfe1b..0237e7693c 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx @@ -9,7 +9,7 @@ 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"; +import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; const USDC_CANONICAL = `USDC:${USDC_ISSUER}`; @@ -84,9 +84,10 @@ describe("SwapAmount direction toggle", () => { 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); + jest.spyOn(FetchBackendV2, "fetchBackendV2").mockResolvedValue({ + status: 200, + body: { data: { routes: [], unavailable: [] } }, + }); }); afterEach(() => jest.restoreAllMocks()); 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 index fd11808384..5037bc3d0d 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx @@ -9,7 +9,7 @@ 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"; +import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; const USDC_CANONICAL = `USDC:${USDC_ISSUER}`; @@ -81,9 +81,10 @@ describe("SwapAmount fiat label", () => { isQuoteExpired: false, fetchData: jest.fn().mockResolvedValue(undefined), } as any); - jest - .spyOn(HorizonGetBestPath, "horizonGetBestPath") - .mockResolvedValue(null as any); + jest.spyOn(FetchBackendV2, "fetchBackendV2").mockResolvedValue({ + status: 200, + body: { data: { routes: [], unavailable: [] } }, + }); }); afterEach(() => jest.restoreAllMocks()); 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 index fc2946c235..853ed7bbf9 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -1,15 +1,20 @@ import React from "react"; -import { render, act } from "@testing-library/react"; +import { render, act, screen, within } from "@testing-library/react"; import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { AppDataType } from "helpers/hooks/useGetAppData"; -import { Wrapper } from "popup/__testHelpers__"; -import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { Wrapper, getTestStore } from "popup/__testHelpers__"; +import { + initialState as transactionSubmissionInitialState, + saveAmount, +} 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 FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; const nativeBalance = { @@ -29,12 +34,36 @@ const swapData = { }; const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +const classicRoute = { + source: "classic", + fromAmount: "5", + toAmount: "42", + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: "XLM", to: USDC }, + { venue: "stellar-dex", from: USDC, to: AQUA }, + ], + }, + ], + fees: [], +}; +const lifiRoute = { ...classicRoute, source: "lifi", toAmount: "50" }; + +const quotesReply = (routes: unknown[]) => ({ + status: 200, + body: { data: { routes, unavailable: [] } }, +}); const renderSwapAmount = (transactionData: Record) => render( ) => ); describe("SwapAmount live receive-amount quote", () => { - let getBestPath: jest.SpyInstance; + let fetchBackendV2: jest.SpyInstance; + let horizonGetBestPath: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); @@ -89,9 +119,12 @@ describe("SwapAmount live receive-amount quote", () => { isQuoteExpired: false, fetchData: jest.fn().mockResolvedValue(undefined), } as any); - getBestPath = jest + fetchBackendV2 = jest + .spyOn(FetchBackendV2, "fetchBackendV2") + .mockResolvedValue(quotesReply([lifiRoute, classicRoute])); + horizonGetBestPath = jest .spyOn(HorizonGetBestPath, "horizonGetBestPath") - .mockResolvedValue({ destination_amount: "42", path: [] } as any); + .mockRejectedValue(new Error("Horizon must not be called")); }); afterEach(() => { @@ -100,33 +133,121 @@ describe("SwapAmount live receive-amount quote", () => { jest.restoreAllMocks(); }); - it("runs a debounced path-only quote when a source amount and destination are set", async () => { + const settle = () => + act(async () => { + jest.advanceTimersByTime(600); + }); + + const cta = () => screen.getByTestId("swap-amount-btn-continue"); + + it("runs a debounced send quote against the backend, not Horizon", async () => { renderSwapAmount({ amount: "5", destinationAsset: AQUA }); // Debounced: nothing fires synchronously. - expect(getBestPath).not.toHaveBeenCalled(); + expect(fetchBackendV2).not.toHaveBeenCalled(); - await act(async () => { - jest.advanceTimersByTime(600); - }); + await settle(); - // Lightweight path lookup (not the full simulation) with the typed amount. - expect(getBestPath).toHaveBeenCalledWith( - expect.objectContaining({ + expect(fetchBackendV2).toHaveBeenCalledTimes(1); + expect(fetchBackendV2).toHaveBeenCalledWith({ + method: "POST", + path: "/swap/quotes?network=PUBLIC", + body: JSON.stringify({ + account: "G123", + mode: "send", + fromAsset: "XLM", + toAsset: AQUA, amount: "5", - sourceAsset: "native", - destAsset: AQUA, }), - ); + }); + expect(horizonGetBestPath).not.toHaveBeenCalled(); }); - it("does not quote when there is no destination asset", async () => { - renderSwapAmount({ amount: "5", destinationAsset: "" }); + it("stores the classic route's amount and path, and the full response", async () => { + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + const { transactionData } = + getTestStore()!.getState().transactionSubmission; + expect(transactionData.destinationAmount).toBe("42"); + expect(transactionData.path).toEqual([USDC]); + expect(transactionData.swapQuotes).toEqual({ + routes: [lifiRoute, classicRoute], + unavailable: [], + }); + expect( + within(screen.getByTestId("swap-receive-card")).getByDisplayValue("42"), + ).toBeInTheDocument(); + expect(cta()).toHaveTextContent("Review swap"); + }); + + it("shows no quote when the response has no classic route", async () => { + fetchBackendV2.mockResolvedValue(quotesReply([lifiRoute])); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(fetchBackendV2).toHaveBeenCalled(); + expect(cta()).toHaveTextContent("No quote available"); + expect(cta()).toBeDisabled(); + }); + + it("shows no quote on empty routes", async () => { + fetchBackendV2.mockResolvedValue(quotesReply([])); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(fetchBackendV2).toHaveBeenCalled(); + expect(cta()).toHaveTextContent("No quote available"); + }); + + it("treats a 400 as no quote", async () => { + fetchBackendV2.mockResolvedValue({ + status: 400, + body: { error: "invalid amount" }, + }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(fetchBackendV2).toHaveBeenCalled(); + expect(cta()).toHaveTextContent("No quote available"); + }); + + it.each([502, 503])( + "shows swaps as temporarily unavailable on a %s", + async (status) => { + fetchBackendV2.mockResolvedValue({ status, body: { error: "x" } }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); + expect(cta()).toBeDisabled(); + }, + ); + + it("clears the unavailable state once a quote succeeds", async () => { + fetchBackendV2.mockResolvedValueOnce({ status: 503, body: {} }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + await settle(); + expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); await act(async () => { - jest.advanceTimersByTime(600); + getTestStore()!.dispatch(saveAmount("6")); }); + await settle(); + + expect(cta()).toHaveTextContent("Review swap"); + }); + + it("does not quote when there is no destination asset", async () => { + renderSwapAmount({ amount: "5", destinationAsset: "" }); + + await settle(); - expect(getBestPath).not.toHaveBeenCalled(); + expect(fetchBackendV2).not.toHaveBeenCalled(); }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx new file mode 100644 index 0000000000..b8e3726055 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx @@ -0,0 +1,165 @@ +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 { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; +import { Wrapper, getTestStore } 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"; +import * as XlmReserve from "popup/helpers/xlmReserve"; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC = `USDC:${USDC_ISSUER}`; +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: MAINNET_NETWORK_DETAILS, + icons: {}, + userBalances: { + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("1"), + available: new BigNumber("1"), + blockaidData: {}, + }, + { + token: { code: "USDC", issuer: { key: USDC_ISSUER } }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, + }, + ], + }, + tokenPrices: {}, +}; + +const route = (fromAmount: string, toAmount: string, to: string) => ({ + source: "classic", + fromAmount, + toAmount, + paths: [{ share: "1", hops: [{ venue: "stellar-dex", from: "USDC", to }] }], + fees: [], +}); + +describe("SwapAmount swap-for-reserve quote", () => { + let fetchBackendV2: jest.SpyInstance; + let horizonReceive: jest.SpyInstance; + + 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(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(true); + horizonReceive = jest + .spyOn(HorizonGetBestPath, "horizonGetBestReceivePath") + .mockRejectedValue(new Error("Horizon must not be called")); + fetchBackendV2 = jest + .spyOn(FetchBackendV2, "fetchBackendV2") + .mockImplementation(async ({ body }) => { + const { mode } = JSON.parse(body!); + const r = + mode === "receive" + ? route("3.25", "0.5102041", "XLM") + : route("5", "40", AQUA); + return { + status: 200, + body: { data: { routes: [r], unavailable: [] } }, + }; + }); + }); + afterEach(() => jest.restoreAllMocks()); + + it("prefills the amount from a backend receive quote for ~0.5 XLM", async () => { + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); + }); + await waitFor(() => + expect(screen.getByTestId("XlmReserveSheet")).toBeInTheDocument(), + ); + await act(async () => { + fireEvent.click(screen.getByTestId("XlmReserveSheet__swap-for-reserve")); + }); + + expect(fetchBackendV2).toHaveBeenCalledWith({ + method: "POST", + path: "/swap/quotes?network=PUBLIC", + body: JSON.stringify({ + account: "G123", + mode: "receive", + fromAsset: USDC, + toAsset: "XLM", + // 0.5 XLM / (1 - 2% slippage) + amount: "0.5102041", + }), + }); + expect(horizonReceive).not.toHaveBeenCalled(); + await waitFor(() => + expect( + getTestStore()!.getState().transactionSubmission.transactionData.amount, + ).toBe("3.2500000"), + ); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts index 66a533d205..9dd35211b0 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts @@ -8,6 +8,7 @@ const base: SwapCtaInputs = { isAmountTooHigh: false, insufficientXlmForFees: false, hasNoSwapPath: false, + isSwapUnavailable: false, }; describe("getSwapCtaState", () => { @@ -73,6 +74,19 @@ describe("getSwapCtaState", () => { }); }); + it("flags swaps unavailable when the quote failed for lack of a backend", () => { + expect( + getSwapCtaState({ + ...base, + hasNoSwapPath: true, + isSwapUnavailable: true, + }), + ).toEqual({ + disabled: true, + labelKey: "unavailable", + }); + }); + it("enables Review swap when everything checks out", () => { expect(getSwapCtaState(base)).toEqual({ disabled: false, diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index 7af25efcc6..d5c67cbc03 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -53,6 +53,7 @@ interface GetSwapDerivedDataParams { networkDetails: NetworkDetails; inputType: InputType; isLiveQuoteLoading: boolean; + isSwapUnavailable: boolean; // Balances-derived "this swap adds a trustline", checked against the // UNFILTERED balances (a hidden held asset needs no new trustline). Drives // the reserve math; distinct from destinationIsNonHeld below, which is a @@ -80,6 +81,7 @@ export const getSwapDerivedData = ({ networkDetails, inputType, isLiveQuoteLoading, + isSwapUnavailable, destRequiresTrustline, }: GetSwapDerivedDataParams) => { const sendData = data; @@ -214,6 +216,7 @@ export const getSwapDerivedData = ({ isAmountTooHigh, insufficientXlmForFees, hasNoSwapPath, + isSwapUnavailable, }); return { diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts index 28a1261b33..249a99281a 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts @@ -7,6 +7,7 @@ export type SwapCtaLabelKey = | "insufficientBalance" | "insufficientXlmFees" | "noQuote" + | "unavailable" | "review"; export interface SwapCtaInputs { @@ -17,6 +18,7 @@ export interface SwapCtaInputs { isAmountTooHigh: boolean; insufficientXlmForFees: boolean; hasNoSwapPath: boolean; + isSwapUnavailable: boolean; } export const getSwapCtaState = ({ @@ -27,6 +29,7 @@ export const getSwapCtaState = ({ isAmountTooHigh, insufficientXlmForFees, hasNoSwapPath, + isSwapUnavailable, }: 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 @@ -50,6 +53,9 @@ export const getSwapCtaState = ({ if (insufficientXlmForFees) { return { disabled: true, labelKey: "insufficientXlmFees" }; } + if (hasNoSwapPath && isSwapUnavailable) { + return { disabled: true, labelKey: "unavailable" }; + } if (hasNoSwapPath) { return { disabled: true, labelKey: "noQuote" }; } diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx new file mode 100644 index 0000000000..58e656df0b --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { act, renderHook } from "@testing-library/react"; +import { Asset, TransactionBuilder } from "stellar-sdk"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; +import { Wrapper, getTestStore } from "popup/__testHelpers__"; +import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import * as Blockaid from "popup/helpers/blockaid"; +import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; + +import { useSimulateTxData } from "../useSimulateSwapData"; + +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 = "GCXRLIZUQNZ3YYJDGX6Z445P7FG5WXT7UILBO5CFIYYM7Z7YTIOELC6O"; +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC = `USDC:${USDC_ISSUER}`; +const AQUA_ISSUER = "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; +const AQUA = `AQUA:${AQUA_ISSUER}`; + +const quotes = { + routes: [ + { + source: "classic", + fromAmount: "10", + toAmount: "1.5", + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: "XLM", to: AQUA }, + { venue: "stellar-dex", from: AQUA, to: USDC }, + ], + }, + ], + fees: [], + }, + ], + unavailable: [], +}; + +const renderSimulate = () => + renderHook( + () => + useSimulateTxData({ + publicKey: PUBLIC_KEY, + networkDetails: MAINNET_NETWORK_DETAILS, + simParams: { + sourceAsset: Asset.native(), + destAsset: new Asset("USDC", USDC_ISSUER), + amount: "10", + allowedSlippage: "1", + path: [], + transactionFee: "0.00001", + transactionTimeout: 180, + destRequiresTrustline: false, + }, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + +describe("useSimulateTxData quote when no live quote exists", () => { + let fetchBackendV2: jest.SpyInstance; + let horizonGetBestPath: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(Blockaid, "useScanTx").mockReturnValue({ + scanTx: jest.fn().mockResolvedValue(null), + } as any); + fetchBackendV2 = jest.spyOn(FetchBackendV2, "fetchBackendV2"); + horizonGetBestPath = jest + .spyOn(HorizonGetBestPath, "horizonGetBestPath") + .mockRejectedValue(new Error("Horizon must not be called")); + }); + afterEach(() => jest.restoreAllMocks()); + + it("builds the path payment from the backend's classic route", async () => { + fetchBackendV2.mockResolvedValue({ status: 200, body: { data: quotes } }); + const { result } = renderSimulate(); + + let payload: any; + await act(async () => { + payload = await result.current.fetchData({ amount: "10" }); + }); + + expect(fetchBackendV2).toHaveBeenCalledWith( + expect.objectContaining({ path: "/swap/quotes?network=PUBLIC" }), + ); + expect(horizonGetBestPath).not.toHaveBeenCalled(); + + const tx = TransactionBuilder.fromXDR( + payload.transactionXdr, + MAINNET_NETWORK_DETAILS.networkPassphrase, + ) as any; + const op = tx.operations[0]; + expect(op.type).toBe("pathPaymentStrictSend"); + expect(op.path.map((a: Asset) => `${a.code}:${a.issuer}`)).toEqual([AQUA]); + // 1.5 less 1% slippage + expect(op.destMin).toBe("1.4850000"); + + const { transactionData } = + getTestStore()!.getState().transactionSubmission; + expect(transactionData.swapQuotes).toEqual(quotes); + }); + + it("reports swaps unavailable when the backend can't quote", async () => { + fetchBackendV2.mockResolvedValue({ status: 503, body: {} }); + const { result } = renderSimulate(); + + await act(async () => { + await result.current.fetchData({ amount: "10" }); + }); + + expect(result.current.state.error).toBe( + "Swaps are temporarily unavailable", + ); + }); + + it("reports no path when the response has no classic route", async () => { + fetchBackendV2.mockResolvedValue({ + status: 200, + body: { data: { routes: [], unavailable: [] } }, + }); + const { result } = renderSimulate(); + + await act(async () => { + await result.current.fetchData({ amount: "10" }); + }); + + expect(fetchBackendV2).toHaveBeenCalled(); + expect(result.current.state.error).toBe("No path found for swap."); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 50bde7d646..6cf7811644 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -29,7 +29,8 @@ import { } from "popup/ducks/transactionSubmission"; import { useScanTx } from "popup/helpers/blockaid"; import { BlockAidScanTxResult, ErrorMessage } from "@shared/api/types"; -import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; +import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; +import { getSwapQuote } from "popup/helpers/swapQuote"; import { isQuoteExpiredError } from "popup/helpers/quoteExpiry"; import { isContractId } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; @@ -40,6 +41,7 @@ const scanUrlstub = "internal"; export const ERROR_TO_DISPLAY = { NO_PATH_FOUND: "No path found for swap.", CUSTOM_TOKEN_NOT_SUPPORTED: "Swapping custom tokens is not supported yet.", + SWAPS_UNAVAILABLE: "Swaps are temporarily unavailable", }; const UNKNOWN_ERROR_DISPLAY = @@ -232,6 +234,7 @@ function useSimulateTxData({ memo, path: storedPath, destinationAmount: storedDestinationAmount, + swapQuotes: storedSwapQuotes, } = useSelector(transactionDataSelector); const reduxDispatch = useDispatch(); @@ -271,32 +274,36 @@ function useSimulateTxData({ // exists yet (path may legitimately be empty for a direct swap). let destinationAmount = storedDestinationAmount; let path: string[] = storedPath || []; + let quotes = storedSwapQuotes; if (!destinationAmount || new BigNumber(destinationAmount).lte(0)) { - const bestPath = await horizonGetBestPath({ - amount, - sourceAsset: getCanonicalFromAsset( - sourceAsset.code, - sourceAsset.issuer, - ), - destAsset: getCanonicalFromAsset(destAsset.code, destAsset.issuer), - networkDetails, - }); + let result; + try { + result = await getSwapQuote({ + mode: "send", + amount, + sourceAsset: getCanonicalFromAsset( + sourceAsset.code, + sourceAsset.issuer, + ), + destAsset: getCanonicalFromAsset(destAsset.code, destAsset.issuer), + networkDetails, + publicKey, + }); + } catch (e) { + if (e instanceof SwapQuotesError && e.kind === "unavailable") { + throw new Error(ERROR_TO_DISPLAY.SWAPS_UNAVAILABLE); + } + throw e; + } - if (!bestPath?.destination_amount) { + if (!result.route) { throw new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND); } - destinationAmount = bestPath.destination_amount; - // store in canonical form for easier use - path = []; - 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)); - } - }); + destinationAmount = result.route.destinationAmount; + path = result.route.path; + quotes = result.quotes; } if (destinationRate) { payload.dstAmountPriceUsd = formatAmount( @@ -335,6 +342,7 @@ function useSimulateTxData({ saveSwapBestPath({ path, destinationAmount, + quotes, }), ); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts index f7665e8680..3f84c87bed 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts @@ -5,9 +5,9 @@ import BigNumber from "bignumber.js"; import { NetworkDetails } from "@shared/constants/stellar"; import { AppDispatch } from "popup/App"; import { saveSwapBestPath } from "popup/ducks/transactionSubmission"; +import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; import { cleanAmount } from "popup/helpers/formatters"; -import { getCanonicalFromAsset } from "helpers/stellar"; -import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; +import { getSwapQuote } from "popup/helpers/swapQuote"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -24,6 +24,7 @@ interface UseSwapLiveQuoteParams { destinationAsset: string; destinationAmount: string; networkDetails: NetworkDetails; + publicKey: string; isReviewingTx: boolean; swapAmountData: SwapAmountDataState; dispatch: AppDispatch; @@ -40,7 +41,8 @@ interface UseSwapLiveQuoteParams { * effect (which would loop). * * Returns isLiveQuoteLoading so the CTA can tell "still loading a quote" apart - * from "no path exists". All path results flow through Redux (saveSwapBestPath). + * from "no path exists", and isSwapUnavailable when the backend can't quote at + * all. All path results flow through Redux (saveSwapBestPath). */ export const useSwapLiveQuote = ({ amount, @@ -48,15 +50,27 @@ export const useSwapLiveQuote = ({ destinationAsset, destinationAmount, networkDetails, + publicKey, isReviewingTx, swapAmountData, dispatch, }: UseSwapLiveQuoteParams) => { const [isLiveQuoteLoading, setIsLiveQuoteLoading] = useState(false); + const [isSwapUnavailable, setIsSwapUnavailable] = useState(false); const liveQuoteReqRef = useRef(0); - const liveQuoteArgsRef = useRef({ asset, destinationAsset, networkDetails }); - liveQuoteArgsRef.current = { asset, destinationAsset, networkDetails }; + const liveQuoteArgsRef = useRef({ + asset, + destinationAsset, + networkDetails, + publicKey, + }); + liveQuoteArgsRef.current = { + asset, + destinationAsset, + networkDetails, + publicKey, + }; const destinationAmountRef = useRef(destinationAmount); destinationAmountRef.current = destinationAmount; // Once the review sheet is open the quote is frozen — a late live quote must @@ -72,14 +86,17 @@ export const useSwapLiveQuote = ({ asset: src, destinationAsset: dst, networkDetails: net, + publicKey: account, } = liveQuoteArgsRef.current; (async () => { try { - const bestPath = await horizonGetBestPath({ + const { route, quotes } = await getSwapQuote({ + mode: "send", amount: quoteAmount, sourceAsset: src, destAsset: dst, networkDetails: net, + publicKey: account, }); if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; // superseded by a newer quote, or frozen for review @@ -87,30 +104,23 @@ export const useSwapLiveQuote = ({ // 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)); - } - }); + setIsSwapUnavailable(false); dispatch( saveSwapBestPath({ - path, - destinationAmount: bestPath.destination_amount, + path: route?.path ?? [], + destinationAmount: route?.destinationAmount ?? "0", + quotes, }), ); - } catch { + } catch (e) { if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; } setIsLiveQuoteLoading(false); - // No path / network error: clear the stale received amount. + setIsSwapUnavailable( + e instanceof SwapQuotesError && e.kind === "unavailable", + ); + // No path / bad input / network error: clear the stale amount. dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); } })(); @@ -140,6 +150,7 @@ export const useSwapLiveQuote = ({ // 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); + setIsSwapUnavailable(false); debouncedQuote.cancel(); liveQuoteReqRef.current += 1; if ( @@ -152,5 +163,5 @@ export const useSwapLiveQuote = ({ // 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, asset, destinationAsset, swapAmountData.state]); - return { isLiveQuoteLoading }; + return { isLiveQuoteLoading, isSwapUnavailable }; }; diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 806ab9afaa..4f8b34ec96 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -73,7 +73,7 @@ import { SlideupModal } from "popup/components/SlideupModal"; import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; -import { horizonGetBestReceivePath } from "popup/helpers/horizonGetBestPath"; +import { getSwapQuote } from "popup/helpers/swapQuote"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote"; import { EditSlippage } from "./EditSlippage"; @@ -343,12 +343,13 @@ export const SwapAmount = ({ // Live "You receive" quote as the user types (debounced path-only lookup). // isLiveQuoteLoading lets the CTA tell "loading a quote" apart from "no path". - const { isLiveQuoteLoading } = useSwapLiveQuote({ + const { isLiveQuoteLoading, isSwapUnavailable } = useSwapLiveQuote({ amount, asset, destinationAsset, destinationAmount, networkDetails, + publicKey, isReviewingTx, swapAmountData, dispatch, @@ -429,6 +430,7 @@ export const SwapAmount = ({ networkDetails, inputType, isLiveQuoteLoading, + isSwapUnavailable, destRequiresTrustline, }); @@ -468,20 +470,22 @@ export const SwapAmount = ({ const reserveTarget = new BigNumber(BASE_RESERVE) .dividedBy(1 - slippageFraction) .toFixed(7); - const path = await horizonGetBestReceivePath({ - destinationAmount: reserveTarget, + const { route } = await getSwapQuote({ + mode: "receive", + amount: reserveTarget, sourceAsset: sellCanonical, destAsset: "native", networkDetails, + publicKey, }); - if (path?.source_amount) { + if (route?.sourceAmount) { const sellSpendable = getAvailableBalance({ assetCanonical: sellCanonical, balances: sendData.userBalances.balances, recommendedFee: fee, }); const capped = BigNumber.minimum( - new BigNumber(path.source_amount), + new BigNumber(route.sourceAmount), new BigNumber(sellSpendable), ); dispatch(saveAmount(capped.toFixed(7))); @@ -516,6 +520,7 @@ export const SwapAmount = ({ insufficientBalance: t("Insufficient balance"), insufficientXlmFees: t("Not enough XLM for network fees"), noQuote: t("No quote available"), + unavailable: t("Swaps are temporarily unavailable"), review: t("Review swap"), }; diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 894e671295..de7ad3b8a1 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -25,6 +25,7 @@ import { SoroswapToken, } from "@shared/api/types"; +import { SwapQuotesResponse } from "@shared/api/types/backend-api"; import { NETWORKS, NetworkDetails } from "@shared/constants/stellar"; import { ConfigurableWalletType } from "@shared/constants/hardwareWallet"; import { isCustomNetwork } from "@shared/helpers/stellar"; @@ -538,6 +539,9 @@ interface TransactionData { // Analytics-only: where the swap flow was entered from (`source` on // swap.completed). Reset with the rest of the submission on Swap mount. swapSource: SwapSource | null; + // The backend response behind `path`/`destinationAmount`; null on custom + // networks and whenever the quote is cleared. + swapQuotes: SwapQuotesResponse | null; } interface HardwareWalletData { @@ -602,6 +606,7 @@ export const initialState: InitialState = { isSoroswap: false, manualTransactionFee: null, swapSource: null, + swapQuotes: null, }, transactionSimulation: { response: null, @@ -741,10 +746,18 @@ const transactionSubmissionSlice = createSlice({ saveBalancesToMigrate: (state, action) => { state.transactionData.balancesToMigrate = action.payload; }, - saveSwapBestPath: (state, action) => { + saveSwapBestPath: ( + state, + action: PayloadAction<{ + path: string[]; + destinationAmount: string; + quotes?: SwapQuotesResponse | null; + }>, + ) => { state.transactionData.path = action.payload.path; state.transactionData.destinationAmount = action.payload.destinationAmount; + state.transactionData.swapQuotes = action.payload.quotes ?? null; }, saveDestinationTokenDetails: ( state, diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 77c1501390..b12c8218e3 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -642,6 +642,7 @@ "Swap to USDT0": "Swap to USDT0", "Swapped!": "Swapped!", "Swapping": "Swapping", + "Swaps are temporarily unavailable": "Swaps are temporarily unavailable", "Switch to this network": "Switch to this network", "Switch wallet": "Switch wallet", "Tags": "Tags", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index c5d4d0c4b4..05f16bfd8c 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -642,6 +642,7 @@ "Swap to USDT0": "Trocar para USDT0", "Swapped!": "Trocado!", "Swapping": "Trocando", + "Swaps are temporarily unavailable": "As trocas estão temporariamente indisponíveis", "Switch to this network": "Alternar para esta rede", "Switch wallet": "Trocar carteira", "Tags": "Categorias", From 058571e79dde6b7c203f05a2b54d7d562d511f3a Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 4 Nov 2026 21:35:51 -0500 Subject: [PATCH 03/15] fix(swap): key stored quotes to their request; show outages as unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review reused the stored live quote whenever it was non-zero, so a quote for an earlier amount, another destination, or one that expired at submit could be built with the wrong destMin and path. The stored quote now carries the request it answered (account, network, assets, amount, mode). Review reuses it only on an exact match and otherwise re-quotes. A quote that expires at submit drops its key. A live quote that lands after the screen unmounts is discarded. An unreachable backend (fetchBackendV2 rejecting) and a 200 whose classic source reported error/timeout/rate_limited showed "No quote available" and reached no Sentry. Both are now "Swaps are temporarily unavailable" and are captured (spec §8). no_route/insufficient_balance and 400 keep the no-quote state. Co-Authored-By: Claude Opus 5.5 --- .../helpers/__tests__/getSwapQuotes.test.ts | 20 ++++ @shared/api/helpers/getSwapQuotes.ts | 18 +++- .../__tests__/SwapAmount.liveQuote.test.tsx | 60 ++++++++++++ .../SwapAmount.reserveQuote.test.tsx | 34 ++++++- .../useSimulateSwapData.quote.test.tsx | 94 +++++++++++++++++- .../SwapAmount/hooks/useSimulateSwapData.tsx | 43 +++++--- .../swap/SwapAmount/hooks/useSwapLiveQuote.ts | 11 ++- .../SwapAmount/hooks/useSwapQuoteExpiry.tsx | 4 +- .../__tests__/transactionSubmission.test.ts | 35 +++++++ .../src/popup/ducks/transactionSubmission.ts | 8 ++ .../popup/helpers/__tests__/swapQuote.test.ts | 56 ++++++++++- extension/src/popup/helpers/swapQuote.ts | 98 +++++++++++++++---- 12 files changed, 431 insertions(+), 50 deletions(-) diff --git a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts index 9a24ea2042..fdab82ec18 100644 --- a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts +++ b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts @@ -1,3 +1,5 @@ +import { captureException } from "@sentry/browser"; + import { getSwapQuotes, SwapQuotesError } from "../getSwapQuotes"; import { fetchBackendV2 } from "../fetchBackendV2"; @@ -94,6 +96,24 @@ describe("getSwapQuotes", () => { }, ); + it("reports an unreachable backend as swaps unavailable", async () => { + mockedFetch.mockRejectedValue(new TypeError("Failed to fetch")); + + const err = await getSwapQuotes(request).catch((e) => e); + expect(err).toBeInstanceOf(SwapQuotesError); + expect(err.kind).toBe("unavailable"); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it("reports a 5xx to Sentry but not a 400", async () => { + mockedFetch.mockResolvedValueOnce({ status: 503, body: {} }); + await getSwapQuotes(request).catch(() => undefined); + mockedFetch.mockResolvedValueOnce({ status: 400, body: {} }); + await getSwapQuotes(request).catch(() => undefined); + + expect(captureException).toHaveBeenCalledTimes(1); + }); + it("rejects a 200 without a routes list as swaps unavailable", async () => { mockedFetch.mockResolvedValue({ status: 200, body: { data: {} } }); diff --git a/@shared/api/helpers/getSwapQuotes.ts b/@shared/api/helpers/getSwapQuotes.ts index 68bb0814b2..168e8f5a9c 100644 --- a/@shared/api/helpers/getSwapQuotes.ts +++ b/@shared/api/helpers/getSwapQuotes.ts @@ -31,11 +31,19 @@ export const getSwapQuotes = async ({ toAsset: string; amount: string; }): Promise => { - const { status, body } = await fetchBackendV2({ - method: "POST", - path: `/swap/quotes?network=${network}`, - body: JSON.stringify({ account, mode, fromAsset, toAsset, amount }), - }); + let status: number; + let body: unknown; + try { + ({ status, body } = await fetchBackendV2({ + method: "POST", + path: `/swap/quotes?network=${network}`, + body: JSON.stringify({ account, mode, fromAsset, toAsset, amount }), + })); + } catch (e) { + const message = `Failed to reach swap quotes - ${e instanceof Error ? e.message : String(e)}`; + captureException(message); + throw new SwapQuotesError("unavailable", message); + } const data = (body as { data?: SwapQuotesResponse } | null)?.data; if (status === 200 && Array.isArray(data?.routes)) { 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 index 853ed7bbf9..6244b727ce 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -176,6 +176,15 @@ describe("SwapAmount live receive-amount quote", () => { routes: [lifiRoute, classicRoute], unavailable: [], }); + expect(transactionData.swapQuoteKey).toEqual({ + account: "G123", + network: "PUBLIC", + networkUrl: MAINNET_NETWORK_DETAILS.networkUrl, + mode: "send", + sourceAsset: "native", + destAsset: AQUA, + amount: "5", + }); expect( within(screen.getByTestId("swap-receive-card")).getByDisplayValue("42"), ).toBeInTheDocument(); @@ -229,6 +238,57 @@ describe("SwapAmount live receive-amount quote", () => { }, ); + it("shows swaps as temporarily unavailable when the backend is unreachable", async () => { + fetchBackendV2.mockRejectedValue(new TypeError("Failed to fetch")); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); + }); + + it("shows swaps as temporarily unavailable when classic errored", async () => { + fetchBackendV2.mockResolvedValue({ + status: 200, + body: { + data: { + routes: [lifiRoute], + unavailable: [{ source: "classic", reason: "timeout" }], + }, + }, + }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); + }); + + it("drops a quote that lands after the screen unmounts", async () => { + let resolve: (v: unknown) => void = () => {}; + fetchBackendV2.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + const { unmount } = renderSwapAmount({ + amount: "5", + destinationAsset: AQUA, + }); + await settle(); + expect(fetchBackendV2).toHaveBeenCalledTimes(1); + + unmount(); + await act(async () => { + resolve(quotesReply([classicRoute])); + }); + + const { transactionData } = + getTestStore()!.getState().transactionSubmission; + expect(transactionData.destinationAmount).toBe("0"); + expect(transactionData.swapQuotes).toBeNull(); + }); + it("clears the unavailable state once a quote succeeds", async () => { fetchBackendV2.mockResolvedValueOnce({ status: 503, body: {} }); renderSwapAmount({ amount: "5", destinationAsset: AQUA }); diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx index b8e3726055..20604548c1 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx @@ -7,6 +7,7 @@ import { act, } from "@testing-library/react"; import BigNumber from "bignumber.js"; +import { captureException } from "@sentry/browser"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -21,6 +22,11 @@ import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; import * as XlmReserve from "popup/helpers/xlmReserve"; +jest.mock("@sentry/browser", () => ({ + ...jest.requireActual("@sentry/browser"), + captureException: jest.fn(), +})); + const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; const USDC = `USDC:${USDC_ISSUER}`; const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; @@ -97,7 +103,7 @@ describe("SwapAmount swap-for-reserve quote", () => { }); afterEach(() => jest.restoreAllMocks()); - it("prefills the amount from a backend receive quote for ~0.5 XLM", async () => { + const renderAndSwapForReserve = async () => { render( { await act(async () => { fireEvent.click(screen.getByTestId("XlmReserveSheet__swap-for-reserve")); }); + }; + it("prefills the amount from a backend receive quote for ~0.5 XLM", async () => { + await renderAndSwapForReserve(); expect(fetchBackendV2).toHaveBeenCalledWith({ method: "POST", path: "/swap/quotes?network=PUBLIC", @@ -162,4 +171,27 @@ describe("SwapAmount swap-for-reserve quote", () => { ).toBe("3.2500000"), ); }); + + it("reports a classic outage instead of treating it as no route", async () => { + fetchBackendV2.mockImplementation(async () => ({ + status: 200, + body: { + data: { + routes: [], + unavailable: [{ source: "classic", reason: "timeout" }], + }, + }, + })); + + await renderAndSwapForReserve(); + + await waitFor(() => + expect(captureException).toHaveBeenCalledWith( + "Swap quotes: classic source timeout", + ), + ); + expect( + getTestStore()!.getState().transactionSubmission.transactionData.amount, + ).toBe("5"); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx index 58e656df0b..d1d64d155a 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx @@ -48,7 +48,30 @@ const quotes = { unavailable: [], }; -const renderSimulate = () => +const storedKey = { + account: PUBLIC_KEY, + network: "PUBLIC", + networkUrl: MAINNET_NETWORK_DETAILS.networkUrl, + mode: "send", + sourceAsset: "native", + destAsset: USDC, + amount: "10", +}; + +const storedQuote = { + destinationAmount: "1.6", + path: [] as string[], + swapQuotes: quotes, + swapQuoteKey: storedKey, +}; + +const renderSimulate = ( + stored: Record = { + // No live quote yet: the hook must fetch one. + destinationAmount: "", + path: [], + }, +) => renderHook( () => useSimulateTxData({ @@ -74,9 +97,7 @@ const renderSimulate = () => ...transactionSubmissionInitialState, transactionData: { ...transactionSubmissionInitialState.transactionData, - // No live quote yet: the hook must fetch one. - destinationAmount: "", - path: [], + ...stored, }, }, }} @@ -158,4 +179,69 @@ describe("useSimulateTxData quote when no live quote exists", () => { expect(fetchBackendV2).toHaveBeenCalled(); expect(result.current.state.error).toBe("No path found for swap."); }); + + describe("with a stored live quote", () => { + const opOf = (payload: any) => + ( + TransactionBuilder.fromXDR( + payload.transactionXdr, + MAINNET_NETWORK_DETAILS.networkPassphrase, + ) as any + ).operations[0]; + + it("reuses it when it answered these exact inputs", async () => { + const { result } = renderSimulate(storedQuote); + + let payload: any; + await act(async () => { + payload = await result.current.fetchData({ amount: "10" }); + }); + + expect(fetchBackendV2).not.toHaveBeenCalled(); + // 1.6 less 1% slippage + expect(opOf(payload).destMin).toBe("1.5840000"); + }); + + it("re-quotes when the amount changed before the debounced quote landed", async () => { + fetchBackendV2.mockResolvedValue({ status: 200, body: { data: quotes } }); + const { result } = renderSimulate(storedQuote); + + await act(async () => { + await result.current.fetchData({ amount: "100" }); + }); + + expect(fetchBackendV2).toHaveBeenCalledTimes(1); + expect(JSON.parse(fetchBackendV2.mock.calls[0][0].body).amount).toBe( + "100", + ); + }); + + it("re-quotes when the stored quote was for another destination", async () => { + fetchBackendV2.mockResolvedValue({ status: 200, body: { data: quotes } }); + const { result } = renderSimulate({ + ...storedQuote, + swapQuoteKey: { ...storedKey, destAsset: AQUA }, + }); + + await act(async () => { + await result.current.fetchData({ amount: "10" }); + }); + + expect(fetchBackendV2).toHaveBeenCalledTimes(1); + }); + + it("re-quotes when the stored quote has no key (expired at submit)", async () => { + fetchBackendV2.mockResolvedValue({ status: 200, body: { data: quotes } }); + const { result } = renderSimulate({ ...storedQuote, swapQuoteKey: null }); + + let payload: any; + await act(async () => { + payload = await result.current.fetchData({ amount: "10" }); + }); + + expect(fetchBackendV2).toHaveBeenCalledTimes(1); + // fresh quote's 1.5, not the stored 1.6 + expect(opOf(payload).destMin).toBe("1.4850000"); + }); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 6cf7811644..ff546d0684 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -30,7 +30,11 @@ import { import { useScanTx } from "popup/helpers/blockaid"; import { BlockAidScanTxResult, ErrorMessage } from "@shared/api/types"; import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; -import { getSwapQuote } from "popup/helpers/swapQuote"; +import { + getSwapQuote, + getSwapQuoteKey, + isSameSwapQuoteKey, +} from "popup/helpers/swapQuote"; import { isQuoteExpiredError } from "popup/helpers/quoteExpiry"; import { isContractId } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; @@ -235,6 +239,7 @@ function useSimulateTxData({ path: storedPath, destinationAmount: storedDestinationAmount, swapQuotes: storedSwapQuotes, + swapQuoteKey: storedSwapQuoteKey, } = useSelector(transactionDataSelector); const reduxDispatch = useDispatch(); @@ -270,26 +275,32 @@ function useSimulateTxData({ // path + destination amount are in Redux) instead of re-fetching, so the // reviewed amount matches exactly what the user saw — no last-moment jump. // Any drift by submit time is bounded by destMin (slippage) and recovered - // via the quote-expiry flow. Only fetch a fresh path when no live quote - // exists yet (path may legitimately be empty for a direct swap). + // via the quote-expiry flow. Fetch a fresh path when no live quote exists + // for these exact inputs (path may legitimately be empty for a direct swap). let destinationAmount = storedDestinationAmount; let path: string[] = storedPath || []; let quotes = storedSwapQuotes; + let key = storedSwapQuoteKey; + const quoteParams = { + mode: "send" as const, + amount, + sourceAsset: getCanonicalFromAsset( + sourceAsset.code, + sourceAsset.issuer, + ), + destAsset: getCanonicalFromAsset(destAsset.code, destAsset.issuer), + networkDetails, + publicKey, + }; - if (!destinationAmount || new BigNumber(destinationAmount).lte(0)) { + if ( + !isSameSwapQuoteKey(storedSwapQuoteKey, getSwapQuoteKey(quoteParams)) || + !destinationAmount || + new BigNumber(destinationAmount).lte(0) + ) { let result; try { - result = await getSwapQuote({ - mode: "send", - amount, - sourceAsset: getCanonicalFromAsset( - sourceAsset.code, - sourceAsset.issuer, - ), - destAsset: getCanonicalFromAsset(destAsset.code, destAsset.issuer), - networkDetails, - publicKey, - }); + result = await getSwapQuote(quoteParams); } catch (e) { if (e instanceof SwapQuotesError && e.kind === "unavailable") { throw new Error(ERROR_TO_DISPLAY.SWAPS_UNAVAILABLE); @@ -304,6 +315,7 @@ function useSimulateTxData({ destinationAmount = result.route.destinationAmount; path = result.route.path; quotes = result.quotes; + key = result.key; } if (destinationRate) { payload.dstAmountPriceUsd = formatAmount( @@ -343,6 +355,7 @@ function useSimulateTxData({ path, destinationAmount, quotes, + key, }), ); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts index 3f84c87bed..5d90eae768 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts @@ -90,7 +90,7 @@ export const useSwapLiveQuote = ({ } = liveQuoteArgsRef.current; (async () => { try { - const { route, quotes } = await getSwapQuote({ + const { route, quotes, key } = await getSwapQuote({ mode: "send", amount: quoteAmount, sourceAsset: src, @@ -110,6 +110,7 @@ export const useSwapLiveQuote = ({ path: route?.path ?? [], destinationAmount: route?.destinationAmount ?? "0", quotes, + key, }), ); } catch (e) { @@ -129,7 +130,13 @@ export const useSwapLiveQuote = ({ [], ); - useEffect(() => () => debouncedQuote.cancel(), [debouncedQuote]); + useEffect( + () => () => { + debouncedQuote.cancel(); + liveQuoteReqRef.current += 1; + }, + [debouncedQuote], + ); useEffect(() => { if ( diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapQuoteExpiry.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapQuoteExpiry.tsx index 3a4fd09cf3..b4ea17c904 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapQuoteExpiry.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapQuoteExpiry.tsx @@ -49,8 +49,8 @@ export const useSwapQuoteExpiry = ({ // 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 getBestPath's retry - // logic; this only emits + surfaces the message. + // the user-facing toast. Nothing re-quotes here; this only emits + surfaces + // the message. useEffect(() => { if (!isQuoteExpired) { return; diff --git a/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts index d8ab04c5c9..dca8f49b91 100644 --- a/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts +++ b/extension/src/popup/ducks/__tests__/transactionSubmission.test.ts @@ -15,6 +15,7 @@ import { submitFreighterTransaction, initialState, DestinationTokenDetails, + saveSwapBestPath, } from "../transactionSubmission"; const makeStore = () => @@ -132,6 +133,40 @@ describe("transactionSubmission isSwapQuoteExpired", () => { }); }); +describe("transactionSubmission swapQuoteKey", () => { + const key = { + account: "GCXRLIZUQNZ3YYJDGX6Z445P7FG5WXT7UILBO5CFIYYM7Z7YTIOELC6O", + network: NETWORKS.PUBLIC, + networkUrl: "https://horizon.stellar.org", + mode: "send" as const, + sourceAsset: "native", + destAsset: "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + amount: "10", + }; + const storedKey = (store: ReturnType) => + store.getState().transactionSubmission.transactionData.swapQuoteKey; + + it("stores the request a quote answered", () => { + const store = makeStore(); + store.dispatch(saveSwapBestPath({ path: [], destinationAmount: "1", key })); + expect(storedKey(store)).toEqual(key); + }); + + it("drops the key when the quote expires at submit, so retry re-quotes", () => { + const store = makeStore(); + store.dispatch(saveSwapBestPath({ path: [], destinationAmount: "1", key })); + store.dispatch(rejectedWith(["op_under_dest_min"])); + expect(storedKey(store)).toBeNull(); + }); + + it("keeps the key on a submit failure that isn't an expiry", () => { + const store = makeStore(); + store.dispatch(saveSwapBestPath({ path: [], destinationAmount: "1", key })); + store.dispatch(rejectedWith(["op_underfunded"])); + expect(storedKey(store)).toEqual(key); + }); +}); + describe("transactionSubmission removeTokenId", () => { const CONTRACT = "CTOKEN"; diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index de7ad3b8a1..fe4b124457 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -26,6 +26,7 @@ import { } from "@shared/api/types"; import { SwapQuotesResponse } from "@shared/api/types/backend-api"; +import type { SwapQuoteKey } from "popup/helpers/swapQuote"; import { NETWORKS, NetworkDetails } from "@shared/constants/stellar"; import { ConfigurableWalletType } from "@shared/constants/hardwareWallet"; import { isCustomNetwork } from "@shared/helpers/stellar"; @@ -542,6 +543,7 @@ interface TransactionData { // The backend response behind `path`/`destinationAmount`; null on custom // networks and whenever the quote is cleared. swapQuotes: SwapQuotesResponse | null; + swapQuoteKey: SwapQuoteKey | null; } interface HardwareWalletData { @@ -607,6 +609,7 @@ export const initialState: InitialState = { manualTransactionFee: null, swapSource: null, swapQuotes: null, + swapQuoteKey: null, }, transactionSimulation: { response: null, @@ -752,12 +755,14 @@ const transactionSubmissionSlice = createSlice({ path: string[]; destinationAmount: string; quotes?: SwapQuotesResponse | null; + key?: SwapQuoteKey | null; }>, ) => { state.transactionData.path = action.payload.path; state.transactionData.destinationAmount = action.payload.destinationAmount; state.transactionData.swapQuotes = action.payload.quotes ?? null; + state.transactionData.swapQuoteKey = action.payload.key ?? null; }, saveDestinationTokenDetails: ( state, @@ -777,6 +782,9 @@ const transactionSubmissionSlice = createSlice({ // instead of dead-ending in SubmitFail. These op codes only arise from // swap path payments, so this is a no-op for sends. state.isSwapQuoteExpired = isQuoteExpiredError(action.payload); + if (state.isSwapQuoteExpired) { + state.transactionData.swapQuoteKey = null; + } }); builder.addCase(submitFreighterTransaction.fulfilled, (state, action) => { state.submitStatus = ActionStatus.SUCCESS; diff --git a/extension/src/popup/helpers/__tests__/swapQuote.test.ts b/extension/src/popup/helpers/__tests__/swapQuote.test.ts index 50f46bcb86..ca96cbf58b 100644 --- a/extension/src/popup/helpers/__tests__/swapQuote.test.ts +++ b/extension/src/popup/helpers/__tests__/swapQuote.test.ts @@ -3,11 +3,14 @@ import { NetworkDetails, } from "@shared/constants/stellar"; import { CUSTOM_NETWORK } from "@shared/helpers/stellar"; +import { captureException } from "@sentry/browser"; import * as GetSwapQuotes from "@shared/api/helpers/getSwapQuotes"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; import { getSwapQuote } from "../swapQuote"; +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); + const ACCOUNT = "GCXRLIZUQNZ3YYJDGX6Z445P7FG5WXT7UILBO5CFIYYM7Z7YTIOELC6O"; const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; @@ -63,7 +66,10 @@ describe("getSwapQuote", () => { "horizonGetBestReceivePath", ); }); - afterEach(() => jest.restoreAllMocks()); + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); describe("on a standard network", () => { it("asks the backend, not Horizon, with backend asset ids", async () => { @@ -131,6 +137,7 @@ describe("getSwapQuote", () => { path: [AQUA], }, quotes, + key: expect.any(Object), }); }); @@ -177,7 +184,45 @@ describe("getSwapQuote", () => { publicKey: ACCOUNT, }); - expect(result).toEqual({ route: null, quotes }); + expect(result).toEqual({ route: null, quotes, key: expect.any(Object) }); + }); + }); + + describe("when classic can't quote", () => { + const quoteWith = (unavailable: { source: string; reason: string }[]) => { + getSwapQuotes.mockResolvedValue({ routes: [lifiRoute], unavailable }); + return getSwapQuote({ + mode: "send", + amount: "100", + sourceAsset: "native", + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + }; + + it.each(["error", "timeout", "rate_limited"])( + "reports classic %s as swaps unavailable, to Sentry", + async (reason) => { + const err = await quoteWith([{ source: "classic", reason }]).catch( + (e) => e, + ); + + expect(err).toBeInstanceOf(GetSwapQuotes.SwapQuotesError); + expect(err.kind).toBe("unavailable"); + expect(captureException).toHaveBeenCalledTimes(1); + }, + ); + + it.each([ + [[{ source: "classic", reason: "no_route" }]], + [[{ source: "classic", reason: "insufficient_balance" }]], + [[{ source: "lifi", reason: "error" }]], + [[]], + ])("has no route for unavailable %j", async (unavailable) => { + const result = await quoteWith(unavailable); + + expect(result.route).toBeNull(); }); }); @@ -219,6 +264,7 @@ describe("getSwapQuote", () => { path: [AQUA, "native"], }, quotes: null, + key: expect.any(Object), }); }); @@ -263,7 +309,11 @@ describe("getSwapQuote", () => { publicKey: ACCOUNT, }); - expect(result).toEqual({ route: null, quotes: null }); + expect(result).toEqual({ + route: null, + quotes: null, + key: expect.any(Object), + }); }); }); }); diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index bc065baabc..e94882b6db 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -1,6 +1,11 @@ +import { captureException } from "@sentry/browser"; +import BigNumber from "bignumber.js"; import { Horizon, StrKey } from "stellar-sdk"; -import { getSwapQuotes } from "@shared/api/helpers/getSwapQuotes"; +import { + getSwapQuotes, + SwapQuotesError, +} from "@shared/api/helpers/getSwapQuotes"; import { SwapQuoteMode, SwapQuotesResponse, @@ -25,13 +30,68 @@ export interface SwapQuoteRoute { path: string[]; } +/** The request a quote answered; a stored quote is only reused for an equal key. */ +export interface SwapQuoteKey { + account: string; + network: string; + networkUrl: string; + mode: SwapQuoteMode; + sourceAsset: string; + destAsset: string; + amount: string; +} + export interface SwapQuoteResult { /** null = no route. */ route: SwapQuoteRoute | null; /** The full backend response; null on custom networks (Horizon). */ quotes: SwapQuotesResponse | null; + key: SwapQuoteKey; +} + +interface SwapQuoteParams { + mode: SwapQuoteMode; + amount: string; + sourceAsset: string; + destAsset: string; + networkDetails: NetworkDetails; + publicKey: string; } +export const getSwapQuoteKey = ({ + mode, + amount, + sourceAsset, + destAsset, + networkDetails, + publicKey, +}: SwapQuoteParams): SwapQuoteKey => ({ + account: publicKey, + network: networkDetails.network, + networkUrl: networkDetails.networkUrl, + mode, + sourceAsset, + destAsset, + amount: cleanAmount(amount), +}); + +export const isSameSwapQuoteKey = ( + a: SwapQuoteKey | null | undefined, + b: SwapQuoteKey, +) => + !!a && + a.account === b.account && + a.network === b.network && + a.networkUrl === b.networkUrl && + a.mode === b.mode && + a.sourceAsset === b.sourceAsset && + a.destAsset === b.destAsset && + new BigNumber(a.amount).isEqualTo(b.amount); + +// Classic reasons that mean the source is down rather than that the pair has +// no route. +const CLASSIC_OUTAGE_REASONS = ["error", "timeout", "rate_limited"]; + // The client still builds a classic path payment itself, so only the classic // route is usable, even when another source quotes better. export const pickSwapRoute = (quotes: SwapQuotesResponse) => @@ -83,21 +143,12 @@ const routeFromHorizon = ( * `amount` is sold in "send" mode and received in "receive" mode. Custom * networks use Horizon directly because the backend can't reach them. */ -export const getSwapQuote = async ({ - mode, - amount, - sourceAsset, - destAsset, - networkDetails, - publicKey, -}: { - mode: SwapQuoteMode; - amount: string; - sourceAsset: string; - destAsset: string; - networkDetails: NetworkDetails; - publicKey: string; -}): Promise => { +export const getSwapQuote = async ( + params: SwapQuoteParams, +): Promise => { + const { mode, amount, sourceAsset, destAsset, networkDetails, publicKey } = + params; + const key = getSwapQuoteKey(params); if (isCustomNetwork(networkDetails)) { const record = mode === "send" @@ -113,7 +164,7 @@ export const getSwapQuote = async ({ destAsset, networkDetails, }); - return { route: routeFromHorizon(record), quotes: null }; + return { route: routeFromHorizon(record), quotes: null, key }; } const quotes = await getSwapQuotes({ @@ -125,5 +176,16 @@ export const getSwapQuote = async ({ amount: cleanAmount(amount), }); const route = pickSwapRoute(quotes); - return { route: route ? routeFromBackend(route) : null, quotes }; + if (!route) { + const outage = quotes.unavailable.find( + (u) => + u.source === "classic" && CLASSIC_OUTAGE_REASONS.includes(u.reason), + ); + if (outage) { + const message = `Swap quotes: classic source ${outage.reason}`; + captureException(message); + throw new SwapQuotesError("unavailable", message); + } + } + return { route: route ? routeFromBackend(route) : null, quotes, key }; }; From 770289479f5f2169e931d7d3f9adb346594a7c39 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 4 Nov 2026 21:51:13 -0500 Subject: [PATCH 04/15] fix(swap): plain-decimal quote amounts; review and reserve-sheet quote fixes - Quote amounts are sent and keyed as plain decimals (BigNumber toFixed). The backend rejects "5.", ".5" and exponents. The percentage buttons and the fiat-to-crypto sync produced "1e-7" via toString, which cleanAmount then turned into "17". - A 400 when review re-quotes shows "No quote available", as the live CTA does, instead of the generic error. - Starting the review cancels any pending or in-flight live quote, so it can't overwrite the quote the review builds. This covers the reserve- sheet path too. - The reserve prefill no longer re-reports SwapQuotesError to Sentry. getSwapQuotes already reports outages, and 400s stay out of Sentry. - The live quote re-runs when the account or network changes. Co-Authored-By: Claude Opus 5.5 --- .../helpers/__tests__/getSwapQuotes.test.ts | 2 +- .../__tests__/SwapAmount.liveQuote.test.tsx | 96 ++++++++++++++++++- .../SwapAmount.reserveQuote.test.tsx | 21 +++- .../useSimulateSwapData.quote.test.tsx | 14 +++ .../hooks/__tests__/useSwapLiveQuote.test.ts | 81 ++++++++++++++++ .../SwapAmount/hooks/useSimulateSwapData.tsx | 9 +- .../swap/SwapAmount/hooks/useSwapLiveQuote.ts | 19 +++- .../components/swap/SwapAmount/index.tsx | 39 ++++---- .../popup/helpers/__tests__/swapQuote.test.ts | 22 +++++ extension/src/popup/helpers/swapQuote.ts | 9 +- 10 files changed, 284 insertions(+), 28 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSwapLiveQuote.test.ts diff --git a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts index fdab82ec18..64bf0227db 100644 --- a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts +++ b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts @@ -85,7 +85,7 @@ describe("getSwapQuotes", () => { expect(err.kind).toBe("badRequest"); }); - it.each([502, 503, 401, 500])( + it.each([502, 503, 401, 500, 429])( "rejects a %s as swaps unavailable", async (status) => { mockedFetch.mockResolvedValue({ status, body: { error: "x" } }); 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 index 6244b727ce..1d4ab809a1 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, act, screen, within } from "@testing-library/react"; +import { render, act, screen, within, fireEvent } from "@testing-library/react"; import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; @@ -16,6 +16,7 @@ import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/use import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; +import * as XlmReserve from "popup/helpers/xlmReserve"; const nativeBalance = { token: { type: "native", code: "XLM" }, @@ -303,6 +304,99 @@ describe("SwapAmount live receive-amount quote", () => { expect(cta()).toHaveTextContent("Review swap"); }); + describe("starting the review with a live quote in flight", () => { + const inFlightLiveQuote = () => { + let resolve: (v: unknown) => void = () => {}; + fetchBackendV2.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + return () => + act(async () => { + resolve(quotesReply([classicRoute])); + }); + }; + const storedDestinationAmount = () => + getTestStore()!.getState().transactionSubmission.transactionData + .destinationAmount; + + it("drops the live quote while the review is being built", async () => { + const resolveLiveQuote = inFlightLiveQuote(); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn(() => new Promise(() => {})), + } as any); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + await settle(); + + await act(async () => { + fireEvent.click(cta()); + }); + await resolveLiveQuote(); + + expect(storedDestinationAmount()).toBe("0"); + }); + + it("drops the live quote when the reserve sheet opens instead", async () => { + const resolveLiveQuote = inFlightLiveQuote(); + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(true); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + await settle(); + + await act(async () => { + fireEvent.click(cta()); + }); + expect(screen.getByTestId("XlmReserveSheet")).toBeInTheDocument(); + await resolveLiveQuote(); + + expect(storedDestinationAmount()).toBe("0"); + }); + }); + + it("quotes a percentage of a tiny balance as a plain decimal", async () => { + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { + ...swapData, + userBalances: { + balances: [ + nativeBalance, + { + token: { + code: "USDC", + issuer: { key: USDC.split(":")[1] }, + }, + total: new BigNumber("0.0000004"), + available: new BigNumber("0.0000004"), + blockaidData: {}, + }, + ], + }, + }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + renderSwapAmount({ asset: USDC, amount: "0", destinationAsset: AQUA }); + + await act(async () => { + fireEvent.click(screen.getByText("25%")); + }); + await settle(); + + expect( + getTestStore()!.getState().transactionSubmission.transactionData.amount, + ).toBe("0.0000001"); + expect(JSON.parse(fetchBackendV2.mock.calls[0][0].body).amount).toBe( + "0.0000001", + ); + }); + it("does not quote when there is no destination asset", async () => { renderSwapAmount({ amount: "5", destinationAsset: "" }); diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx index 20604548c1..8839902d2b 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx @@ -101,7 +101,10 @@ describe("SwapAmount swap-for-reserve quote", () => { }; }); }); - afterEach(() => jest.restoreAllMocks()); + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); const renderAndSwapForReserve = async () => { render( @@ -194,4 +197,20 @@ describe("SwapAmount swap-for-reserve quote", () => { getTestStore()!.getState().transactionSubmission.transactionData.amount, ).toBe("5"); }); + + it("does not report a rejected receive quote to Sentry again", async () => { + fetchBackendV2.mockImplementation(async () => ({ + status: 400, + body: { error: "invalid amount" }, + })); + + await renderAndSwapForReserve(); + + expect(fetchBackendV2).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('"mode":"receive"'), + }), + ); + expect(captureException).not.toHaveBeenCalled(); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx index d1d64d155a..0c778aa7f7 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx @@ -165,6 +165,20 @@ describe("useSimulateTxData quote when no live quote exists", () => { ); }); + it("shows the live CTA's no-quote message when the re-quote is a 400", async () => { + fetchBackendV2.mockResolvedValue({ + status: 400, + body: { error: "account not found" }, + }); + const { result } = renderSimulate(); + + await act(async () => { + await result.current.fetchData({ amount: "10" }); + }); + + expect(result.current.state.error).toBe("No quote available"); + }); + it("reports no path when the response has no classic route", async () => { fetchBackendV2.mockResolvedValue({ status: 200, diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSwapLiveQuote.test.ts b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSwapLiveQuote.test.ts new file mode 100644 index 0000000000..76fb20de45 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSwapLiveQuote.test.ts @@ -0,0 +1,81 @@ +import { act, renderHook } from "@testing-library/react"; + +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { + MAINNET_NETWORK_DETAILS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; +import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; + +import { useSwapLiveQuote } from "../useSwapLiveQuote"; + +const ACCOUNT_A = "GCXRLIZUQNZ3YYJDGX6Z445P7FG5WXT7UILBO5CFIYYM7Z7YTIOELC6O"; +const ACCOUNT_B = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + +const baseProps = { + amount: "5", + asset: "native", + destinationAsset: AQUA, + destinationAmount: "0", + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT_A, + isReviewingTx: false, + swapAmountData: { + state: RequestState.SUCCESS, + data: { type: AppDataType.RESOLVED }, + } as any, + dispatch: jest.fn() as any, +}; + +describe("useSwapLiveQuote re-quote triggers", () => { + let fetchBackendV2: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + fetchBackendV2 = jest + .spyOn(FetchBackendV2, "fetchBackendV2") + .mockResolvedValue({ + status: 200, + body: { data: { routes: [], unavailable: [] } }, + }); + }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + const settle = () => + act(async () => { + jest.advanceTimersByTime(600); + }); + + it.each([ + ["account", { publicKey: ACCOUNT_B }, "account", ACCOUNT_B], + [ + "network", + { networkDetails: TESTNET_NETWORK_DETAILS }, + "path", + "/swap/quotes?network=TESTNET", + ], + ])( + "re-quotes when the %s changes", + async (_label, change, field, expected) => { + const { rerender } = renderHook((props) => useSwapLiveQuote(props), { + initialProps: baseProps, + }); + await settle(); + expect(fetchBackendV2).toHaveBeenCalledTimes(1); + + rerender({ ...baseProps, ...change }); + await settle(); + + expect(fetchBackendV2).toHaveBeenCalledTimes(2); + const call = fetchBackendV2.mock.calls[1][0]; + const actual = + field === "path" ? call.path : JSON.parse(call.body)[field]; + expect(actual).toBe(expected); + }, + ); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index ff546d0684..27d119d546 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -46,6 +46,7 @@ export const ERROR_TO_DISPLAY = { NO_PATH_FOUND: "No path found for swap.", CUSTOM_TOKEN_NOT_SUPPORTED: "Swapping custom tokens is not supported yet.", SWAPS_UNAVAILABLE: "Swaps are temporarily unavailable", + NO_QUOTE: "No quote available", }; const UNKNOWN_ERROR_DISPLAY = @@ -302,8 +303,12 @@ function useSimulateTxData({ try { result = await getSwapQuote(quoteParams); } catch (e) { - if (e instanceof SwapQuotesError && e.kind === "unavailable") { - throw new Error(ERROR_TO_DISPLAY.SWAPS_UNAVAILABLE); + if (e instanceof SwapQuotesError) { + throw new Error( + e.kind === "unavailable" + ? ERROR_TO_DISPLAY.SWAPS_UNAVAILABLE + : ERROR_TO_DISPLAY.NO_QUOTE, + ); } throw e; } diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts index 5d90eae768..8c68da9bd2 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { debounce } from "lodash"; import BigNumber from "bignumber.js"; @@ -130,6 +130,11 @@ export const useSwapLiveQuote = ({ [], ); + const cancelLiveQuote = useCallback(() => { + debouncedQuote.cancel(); + liveQuoteReqRef.current += 1; + }, [debouncedQuote]); + useEffect( () => () => { debouncedQuote.cancel(); @@ -168,7 +173,15 @@ export const useSwapLiveQuote = ({ } } // 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, asset, destinationAsset, swapAmountData.state]); + }, [ + amount, + asset, + destinationAsset, + swapAmountData.state, + publicKey, + networkDetails.network, + networkDetails.networkUrl, + ]); - return { isLiveQuoteLoading, isSwapUnavailable }; + return { isLiveQuoteLoading, isSwapUnavailable, cancelLiveQuote }; }; diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 4f8b34ec96..9a4302226c 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -74,6 +74,7 @@ import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; import { getSwapQuote } from "popup/helpers/swapQuote"; +import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote"; import { EditSlippage } from "./EditSlippage"; @@ -237,6 +238,8 @@ export const SwapAmount = ({ ); const handleContinue = async (values: { amount: string }) => { + // A live quote landing now would overwrite the quote the review builds. + cancelLiveQuote(); // Retrying after a quote-expiry submit failure: dismiss the stale notice // before re-simulating against a fresh quote. if (isSwapQuoteExpired) { @@ -343,17 +346,18 @@ export const SwapAmount = ({ // Live "You receive" quote as the user types (debounced path-only lookup). // isLiveQuoteLoading lets the CTA tell "loading a quote" apart from "no path". - const { isLiveQuoteLoading, isSwapUnavailable } = useSwapLiveQuote({ - amount, - asset, - destinationAsset, - destinationAmount, - networkDetails, - publicKey, - isReviewingTx, - swapAmountData, - dispatch, - }); + const { isLiveQuoteLoading, isSwapUnavailable, cancelLiveQuote } = + useSwapLiveQuote({ + amount, + asset, + destinationAsset, + destinationAmount, + networkDetails, + publicKey, + isReviewingTx, + swapAmountData, + dispatch, + }); if (isLoading) { return ; @@ -508,9 +512,12 @@ export const SwapAmount = ({ } } catch (e) { // No path / network error — leave the amount as-is for manual entry. - captureException( - `Swap-for-reserve prefill failed - ${JSON.stringify(e)}`, - ); + // getSwapQuotes already reported its own failures. + if (!(e instanceof SwapQuotesError)) { + captureException( + `Swap-for-reserve prefill failed - ${JSON.stringify(e)}`, + ); + } } }; @@ -690,7 +697,7 @@ export const SwapAmount = ({ ? new BigNumber(cleanAmount(usd)) .dividedBy(assetPrice) .decimalPlaces(assetDecimals) - .toString() + .toFixed() : DEFAULT_AMOUNT; formik.setFieldValue("amount", crypto); dispatch(saveAmount(crypto)); @@ -829,7 +836,7 @@ export const SwapAmount = ({ ) .multipliedBy(new BigNumber(pct).dividedBy(100)) .decimalPlaces(assetDecimals) - .toString(); + .toFixed(); formik.setFieldValue("amount", pctAmount); dispatch(saveAmount(pctAmount)); if (inputType === "fiat" && assetPrice) { diff --git a/extension/src/popup/helpers/__tests__/swapQuote.test.ts b/extension/src/popup/helpers/__tests__/swapQuote.test.ts index ca96cbf58b..552a38c03d 100644 --- a/extension/src/popup/helpers/__tests__/swapQuote.test.ts +++ b/extension/src/popup/helpers/__tests__/swapQuote.test.ts @@ -188,6 +188,28 @@ describe("getSwapQuote", () => { }); }); + it.each([ + ["5.", "5"], + [".5", "0.5"], + ["0.10", "0.1"], + ])("sends the amount %s as the plain decimal %s", async (typed, sent) => { + getSwapQuotes.mockResolvedValue({ routes: [], unavailable: [] }); + + const { key } = await getSwapQuote({ + mode: "send", + amount: typed, + sourceAsset: "native", + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(getSwapQuotes).toHaveBeenCalledWith( + expect.objectContaining({ amount: sent }), + ); + expect(key.amount).toBe(sent); + }); + describe("when classic can't quote", () => { const quoteWith = (unavailable: { source: string; reason: string }[]) => { getSwapQuotes.mockResolvedValue({ routes: [lifiRoute], unavailable }); diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index e94882b6db..ae85c53f4c 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -72,7 +72,8 @@ export const getSwapQuoteKey = ({ mode, sourceAsset, destAsset, - amount: cleanAmount(amount), + // The backend accepts only plain decimals: no "5.", ".5" or exponent. + amount: new BigNumber(cleanAmount(amount)).toFixed(), }); export const isSameSwapQuoteKey = ( @@ -146,9 +147,9 @@ const routeFromHorizon = ( export const getSwapQuote = async ( params: SwapQuoteParams, ): Promise => { - const { mode, amount, sourceAsset, destAsset, networkDetails, publicKey } = - params; + const { mode, sourceAsset, destAsset, networkDetails, publicKey } = params; const key = getSwapQuoteKey(params); + const { amount } = key; if (isCustomNetwork(networkDetails)) { const record = mode === "send" @@ -173,7 +174,7 @@ export const getSwapQuote = async ( mode, fromAsset: toBackendAsset(sourceAsset), toAsset: toBackendAsset(destAsset), - amount: cleanAmount(amount), + amount, }); const route = pickSwapRoute(quotes); if (!route) { From 6f50255dddaf265282ed04f38377866bfc1157ae Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 18:07:39 -0500 Subject: [PATCH 05/15] fix(swap): show quote loading and gate Review until the quote lands The Review swap CTA was enabled and "You receive" showed a stale value or 0 while a /swap/quotes request was pending (1.7-3.3 s via LI.FI), because hasNoSwapPath was forced false while isLiveQuoteLoading, and for one render after an amount change neither flag said a quote was pending. A quote is now pending when the amount is positive, both tokens are picked, and either a live quote is in flight or the stored quote's key does not match the current inputs. While pending, the CTA keeps the "Review swap" label but is disabled with the Button loading spinner, and the receive card shows the Shimmer placeholder. Balance and XLM-fee blockers still take precedence. A failed live quote now stores its request key so it settles to the existing no-quote / unavailable states instead of staying pending (the review build still re-fetches because the stored amount is 0). Tests: unit and component tests that click Review now wait for the quote. The two "starting the review with a live quote in flight" tests are replaced by one asserting the review cannot start in that state (now unreachable). e2e: swap and blockaidScan specs wait for the CTA to be enabled before clicking; the blockaidScan.safe review assertion drops a .or(getByText ("Review")) that also matched the CTA and went strict-mode flaky once the review opened faster. The quote-expiry recovery e2e now asserts the CTA re-enables after the re-quote. Co-Authored-By: Claude Opus 5.5 --- .../e2e-tests/blockaidScan.malicious.test.ts | 4 + extension/e2e-tests/blockaidScan.safe.test.ts | 8 +- .../e2e-tests/blockaidScan.suspicious.test.ts | 4 + .../e2e-tests/blockaidScan.unable.test.ts | 4 + extension/e2e-tests/swap.test.ts | 25 ++-- .../components/amount/AmountCard/index.tsx | 22 +++- .../__tests__/SwapAmount.ctaGate.test.tsx | 19 +++ .../__tests__/SwapAmount.fiatLabel.test.tsx | 20 +++- .../__tests__/SwapAmount.liveQuote.test.tsx | 109 +++++++++++++----- .../SwapAmount.reserveQuote.test.tsx | 39 ++++--- .../__tests__/SwapAmount.telemetry.test.tsx | 15 +++ .../helpers/__tests__/swapCtaState.test.ts | 22 ++++ .../SwapAmount/helpers/getSwapDerivedData.ts | 36 +++++- .../swap/SwapAmount/helpers/swapCtaState.ts | 11 +- .../swap/SwapAmount/hooks/useSwapLiveQuote.ts | 27 +++-- .../components/swap/SwapAmount/index.tsx | 8 +- 16 files changed, 298 insertions(+), 75 deletions(-) diff --git a/extension/e2e-tests/blockaidScan.malicious.test.ts b/extension/e2e-tests/blockaidScan.malicious.test.ts index e0431f1993..69468af7d9 100644 --- a/extension/e2e-tests/blockaidScan.malicious.test.ts +++ b/extension/e2e-tests/blockaidScan.malicious.test.ts @@ -334,6 +334,10 @@ test.describe("BlockAid Scan - Malicious States", () => { .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("10"); + // Disabled until the quote for the entered amount lands. + await expect(page.getByTestId("swap-amount-btn-continue")).toBeEnabled({ + timeout: 10000, + }); await page.getByTestId("swap-amount-btn-continue").click({ force: true }); // Should be on review pane with warning banner visible diff --git a/extension/e2e-tests/blockaidScan.safe.test.ts b/extension/e2e-tests/blockaidScan.safe.test.ts index b27771da96..80598c4b21 100644 --- a/extension/e2e-tests/blockaidScan.safe.test.ts +++ b/extension/e2e-tests/blockaidScan.safe.test.ts @@ -251,11 +251,13 @@ test.describe("BlockAid Scan - Safe States (No Override)", () => { .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("10"); + // Disabled until the quote for the entered amount lands. + await expect(page.getByTestId("swap-amount-btn-continue")).toBeEnabled({ + timeout: 10000, + }); await page.getByTestId("swap-amount-btn-continue").click({ force: true }); - await expect( - page.getByText("You are swapping").or(page.getByText("Review")), - ).toBeVisible({ + await expect(page.getByText("You are swapping")).toBeVisible({ timeout: 200000, }); diff --git a/extension/e2e-tests/blockaidScan.suspicious.test.ts b/extension/e2e-tests/blockaidScan.suspicious.test.ts index 4abba3ad22..0aa797292d 100644 --- a/extension/e2e-tests/blockaidScan.suspicious.test.ts +++ b/extension/e2e-tests/blockaidScan.suspicious.test.ts @@ -243,6 +243,10 @@ test.describe("BlockAid Scan - Suspicious States", () => { .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("10"); + // Disabled until the quote for the entered amount lands. + await expect(page.getByTestId("swap-amount-btn-continue")).toBeEnabled({ + timeout: 10000, + }); await page.getByTestId("swap-amount-btn-continue").click({ force: true }); // Should be on review pane with warning banner visible diff --git a/extension/e2e-tests/blockaidScan.unable.test.ts b/extension/e2e-tests/blockaidScan.unable.test.ts index ed30e0ab03..653923fa28 100644 --- a/extension/e2e-tests/blockaidScan.unable.test.ts +++ b/extension/e2e-tests/blockaidScan.unable.test.ts @@ -267,6 +267,10 @@ test.describe("BlockAid Scan - Unable to Scan States", () => { .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("10"); + // Disabled until the quote for the entered amount lands. + await expect(page.getByTestId("swap-amount-btn-continue")).toBeEnabled({ + timeout: 10000, + }); await page.getByTestId("swap-amount-btn-continue").click({ force: true }); // Should be on review pane with warning banner visible diff --git a/extension/e2e-tests/swap.test.ts b/extension/e2e-tests/swap.test.ts index 86b2fdedeb..299e06f3d0 100644 --- a/extension/e2e-tests/swap.test.ts +++ b/extension/e2e-tests/swap.test.ts @@ -73,6 +73,13 @@ async function openSwapToPicker(page: Page) { await expect(page.getByText("Swap to")).toBeVisible(); } +// The CTA stays disabled until the quote for the entered amount lands. +async function clickReviewSwap(page: Page) { + const cta = page.getByTestId("swap-amount-btn-continue"); + await expect(cta).toBeEnabled({ timeout: 10000 }); + await cta.click({ force: true }); +} + // --------------------------------------------------------------------------- // 1. Held-to-held regression // Verifies the existing swap flow (source + destination both already held) @@ -138,7 +145,7 @@ test("held-to-held swap reaches review screen", async ({ .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("1"); - await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + await clickReviewSwap(page); // Review screen await expect(page.getByText("You are swapping")).toBeVisible({ @@ -215,7 +222,7 @@ test("swap to new token shows trustline banner at review", async ({ .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("1"); - await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + await clickReviewSwap(page); // Review screen should show trustline banner (AQUA not in account balances) await expect(page.getByTestId("review-tx-trustline-banner")).toBeVisible({ @@ -310,7 +317,7 @@ test("shows XLM-reserve sheet when balance cannot cover the reserve", async ({ .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("0.05"); - await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + await clickReviewSwap(page); // XlmReserveSheet appears because spendable XLM < required reserve await expect(page.getByTestId("XlmReserveSheet")).toBeVisible({ @@ -368,7 +375,7 @@ test("flagged destination surfaces blockaid malicious warning at review", async .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("1"); - await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + await clickReviewSwap(page); // Review screen: the unified transaction Blockaid banner must be visible // (tx scan takes precedence over the token verdict on the review). @@ -577,7 +584,7 @@ test("flagged non-held destination stacks trustline and Blockaid banners", async .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("1"); - await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + await clickReviewSwap(page); // Both banners must render together at review. await expect(page.getByTestId("review-tx-trustline-banner")).toBeVisible({ @@ -676,7 +683,7 @@ test("completes a swap to a new token and shows the success summary", async ({ .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("1"); - await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + await clickReviewSwap(page); // Review renders with the trustline banner (AQUA not yet trusted). await expect(page.getByTestId("review-tx-trustline-banner")).toBeVisible({ @@ -856,7 +863,7 @@ test("recovers to the amount screen when the swap quote expires at submit", asyn .getByTestId("swap-sell-card") .getByTestId("send-amount-amount-input") .fill("1"); - await page.getByTestId("swap-amount-btn-continue").click({ force: true }); + await clickReviewSwap(page); await expect(page.getByTestId("review-tx-trustline-banner")).toBeVisible({ timeout: 30000, @@ -870,6 +877,10 @@ test("recovers to the amount screen when the swap quote expires at submit", asyn page.getByText("Quote has expired, please try again to get a new quote"), ).toBeVisible({ timeout: 30000 }); await expect(page.getByTestId("swap-sell-card")).toBeVisible(); + // The expired quote's key is cleared, so the amount screen re-quotes. + await expect(page.getByTestId("swap-amount-btn-continue")).toBeEnabled({ + timeout: 10000, + }); }); // --------------------------------------------------------------------------- diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index ccf0aa12ad..dd81cffefb 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -3,6 +3,7 @@ import { Button, Icon } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; import { AssetIcon } from "popup/components/account/AccountAssets"; +import { Shimmer } from "popup/components/Shimmer"; import { AssetIcons } from "@shared/api/types"; import { isNativeAssetPair } from "@shared/helpers/assetIdentity"; import { SecurityLevel } from "popup/constants/blockaid"; @@ -34,6 +35,8 @@ export interface AmountCardProps { hasUsdPrice?: boolean; fiatLineText: string; isAmountTooHigh: boolean; + /** Shows a placeholder instead of the amount and fiat line. */ + isAmountLoading?: 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; @@ -70,6 +73,7 @@ export const AmountCard = ({ hasUsdPrice = true, fiatLineText, isAmountTooHigh, + isAmountLoading = false, maxSpendableText = "", isReadOnly = false, autoFocus = true, @@ -154,7 +158,14 @@ export const AmountCard = ({ > {amountUsd || "0"} - {inputType === "crypto" && ( + {isAmountLoading && ( + + )} + {!isAmountLoading && inputType === "crypto" && ( )} - {inputType === "fiat" && + {!isAmountLoading && + inputType === "fiat" && (hasUsdPrice ? ( <>
- {fiatLineText} + {isAmountLoading ? ( + + ) : ( + 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. */} diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx index 4889a81913..597e66f987 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx @@ -17,6 +17,7 @@ import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/us 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 SwapQuote from "popup/helpers/swapQuote"; // Native-XLM balance that makes `availableBalance` > 0 so the "Review swap" // button is not disabled. @@ -62,9 +63,25 @@ describe("SwapAmount CTA gate", () => { state: { state: RequestState.SUCCESS, data: swapData, error: null }, fetchData: jest.fn().mockResolvedValue(undefined), } as any); + jest + .spyOn(SwapQuote, "getSwapQuote") + .mockImplementation(async (params) => ({ + route: { + sourceAmount: params.amount, + destinationAmount: "10", + path: [], + }, + quotes: null, + key: SwapQuote.getSwapQuoteKey(params), + })); }); afterEach(() => jest.restoreAllMocks()); + const waitForQuote = () => + waitFor(() => + expect(screen.getByTestId("swap-amount-btn-continue")).toBeEnabled(), + ); + it("opens the XLM-reserve sheet instead of review when pre-flight gates", async () => { const spyReserve = jest .spyOn(XlmReserve, "shouldShowXlmReservePreflight") @@ -109,6 +126,7 @@ describe("SwapAmount CTA gate", () => { /> , ); + await waitForQuote(); await act(async () => { fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); }); @@ -323,6 +341,7 @@ describe("SwapAmount CTA gate", () => { /> , ); + await waitForQuote(); 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.fiatLabel.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx index 5037bc3d0d..9f980f2362 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.fiatLabel.test.tsx @@ -10,6 +10,7 @@ import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/us import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; +import * as SwapQuote from "popup/helpers/swapQuote"; const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; const USDC_CANONICAL = `USDC:${USDC_ISSUER}`; @@ -85,6 +86,17 @@ describe("SwapAmount fiat label", () => { status: 200, body: { data: { routes: [], unavailable: [] } }, }); + jest + .spyOn(SwapQuote, "getSwapQuote") + .mockImplementation(async (params) => ({ + route: { + sourceAmount: params.amount, + destinationAmount: "10", + path: [], + }, + quotes: null, + key: SwapQuote.getSwapQuoteKey(params), + })); }); afterEach(() => jest.restoreAllMocks()); @@ -98,7 +110,7 @@ describe("SwapAmount fiat label", () => { fetchData: jest.fn().mockResolvedValue(undefined), } as any); - it("shows '--' on both cards when the selected assets have no price", () => { + it("shows '--' on both cards when the selected assets have no price", async () => { mockAmountData({}); renderAmount({ asset: "native", destinationAsset: USDC_CANONICAL }); @@ -106,7 +118,7 @@ describe("SwapAmount fiat label", () => { within(screen.getByTestId("swap-sell-card")).getByText("--"), ).toBeInTheDocument(); expect( - within(screen.getByTestId("swap-receive-card")).getByText("--"), + await within(screen.getByTestId("swap-receive-card")).findByText("--"), ).toBeInTheDocument(); }); @@ -133,7 +145,7 @@ describe("SwapAmount fiat label", () => { ).toBeInTheDocument(); }); - it("falls back to the destination's stellar.expert spot price when /token-prices has none", () => { + it("falls back to the destination's stellar.expert spot price when /token-prices has none", async () => { // tokenPrices is empty -> the receive card relies on the spot-price fallback. mockAmountData({}); renderAmount({ @@ -151,7 +163,7 @@ describe("SwapAmount fiat label", () => { // 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(await within(receive).findByText(/^\$5/)).toBeInTheDocument(); expect(within(receive).queryByText("--")).toBeNull(); }); }); 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 index 1d4ab809a1..f0dcd35513 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -13,10 +13,10 @@ import { 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 UseSwapLiveQuote from "popup/components/swap/SwapAmount/hooks/useSwapLiveQuote"; import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; -import * as XlmReserve from "popup/helpers/xlmReserve"; const nativeBalance = { token: { type: "native", code: "XLM" }, @@ -304,8 +304,8 @@ describe("SwapAmount live receive-amount quote", () => { expect(cta()).toHaveTextContent("Review swap"); }); - describe("starting the review with a live quote in flight", () => { - const inFlightLiveQuote = () => { + describe("while the quote for the current inputs is pending", () => { + const pendingQuote = () => { let resolve: (v: unknown) => void = () => {}; fetchBackendV2.mockReturnValue( new Promise((r) => { @@ -317,46 +317,97 @@ describe("SwapAmount live receive-amount quote", () => { resolve(quotesReply([classicRoute])); }); }; - const storedDestinationAmount = () => - getTestStore()!.getState().transactionSubmission.transactionData - .destinationAmount; - - it("drops the live quote while the review is being built", async () => { - const resolveLiveQuote = inFlightLiveQuote(); - jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ - state: { state: RequestState.IDLE, data: null, error: null }, - isQuoteExpired: false, - fetchData: jest.fn(() => new Promise(() => {})), - } as any); + const receiveCard = () => screen.getByTestId("swap-receive-card"); + const expectQuoteLoading = () => { + expect(cta()).toHaveTextContent("Review swap"); + expect(cta()).toBeDisabled(); + expect(cta().querySelector(".Loader")).not.toBeNull(); + expect( + within(receiveCard()).getByTestId("amount-card-amount-loading"), + ).toBeInTheDocument(); + expect(within(receiveCard()).queryByRole("textbox")).toBeNull(); + }; + + it("shows the CTA and the receive amount loading during the debounce and the fetch", async () => { + pendingQuote(); renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + expectQuoteLoading(); await settle(); + expect(fetchBackendV2).toHaveBeenCalledTimes(1); + expectQuoteLoading(); + }); - await act(async () => { - fireEvent.click(cta()); + it("shows loading when the stored quote is for other inputs, before the live-quote flag flips", () => { + jest.spyOn(UseSwapLiveQuote, "useSwapLiveQuote").mockReturnValue({ + isLiveQuoteLoading: false, + isSwapUnavailable: false, + cancelLiveQuote: jest.fn(), + }); + renderSwapAmount({ + amount: "6", + destinationAsset: AQUA, + destinationAmount: "42", + swapQuoteKey: { + account: "G123", + network: "PUBLIC", + networkUrl: MAINNET_NETWORK_DETAILS.networkUrl, + mode: "send", + sourceAsset: "native", + destAsset: AQUA, + amount: "5", + }, }); - await resolveLiveQuote(); - expect(storedDestinationAmount()).toBe("0"); + expectQuoteLoading(); }); - it("drops the live quote when the reserve sheet opens instead", async () => { - const resolveLiveQuote = inFlightLiveQuote(); - jest - .spyOn(XlmReserve, "shouldShowXlmReservePreflight") - .mockReturnValue(true); + it("enables Review swap once the matching quote lands", async () => { + const resolveQuote = pendingQuote(); renderSwapAmount({ amount: "5", destinationAsset: AQUA }); await settle(); + expectQuoteLoading(); - await act(async () => { - fireEvent.click(cta()); - }); - expect(screen.getByTestId("XlmReserveSheet")).toBeInTheDocument(); - await resolveLiveQuote(); + await resolveQuote(); + + expect(cta()).toHaveTextContent("Review swap"); + expect(cta()).toBeEnabled(); + expect(cta().querySelector(".Loader")).toBeNull(); + expect( + within(receiveCard()).queryByTestId("amount-card-amount-loading"), + ).toBeNull(); + expect(within(receiveCard()).getByDisplayValue("42")).toBeInTheDocument(); + }); - expect(storedDestinationAmount()).toBe("0"); + it("shows insufficient balance without waiting for the quote", async () => { + pendingQuote(); + renderSwapAmount({ amount: "500", destinationAsset: AQUA }); + await settle(); + + expect(cta()).toHaveTextContent("Insufficient balance"); + expect(cta()).toBeDisabled(); + expect(cta().querySelector(".Loader")).toBeNull(); }); }); + it("does not start the review while a live quote is in flight", async () => { + fetchBackendV2.mockReturnValue(new Promise(() => {})); + const fetchSimulation = jest.fn().mockResolvedValue(undefined); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: fetchSimulation, + } as any); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + await settle(); + + await act(async () => { + fireEvent.click(cta()); + }); + + expect(fetchSimulation).not.toHaveBeenCalled(); + }); + it("quotes a percentage of a tiny balance as a plain decimal", async () => { jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ state: { diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx index 8839902d2b..0d83b04139 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx @@ -63,6 +63,11 @@ const route = (fromAmount: string, toAmount: string, to: string) => ({ fees: [], }); +const sendQuoteReply = { + status: 200, + body: { data: { routes: [route("5", "40", AQUA)], unavailable: [] } }, +}; + describe("SwapAmount swap-for-reserve quote", () => { let fetchBackendV2: jest.SpyInstance; let horizonReceive: jest.SpyInstance; @@ -142,6 +147,9 @@ describe("SwapAmount swap-for-reserve quote", () => { , ); + await waitFor(() => + expect(screen.getByTestId("swap-amount-btn-continue")).toBeEnabled(), + ); await act(async () => { fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); }); @@ -176,15 +184,19 @@ describe("SwapAmount swap-for-reserve quote", () => { }); it("reports a classic outage instead of treating it as no route", async () => { - fetchBackendV2.mockImplementation(async () => ({ - status: 200, - body: { - data: { - routes: [], - unavailable: [{ source: "classic", reason: "timeout" }], - }, - }, - })); + fetchBackendV2.mockImplementation(async ({ body }) => + JSON.parse(body!).mode === "send" + ? sendQuoteReply + : { + status: 200, + body: { + data: { + routes: [], + unavailable: [{ source: "classic", reason: "timeout" }], + }, + }, + }, + ); await renderAndSwapForReserve(); @@ -199,10 +211,11 @@ describe("SwapAmount swap-for-reserve quote", () => { }); it("does not report a rejected receive quote to Sentry again", async () => { - fetchBackendV2.mockImplementation(async () => ({ - status: 400, - body: { error: "invalid amount" }, - })); + fetchBackendV2.mockImplementation(async ({ body }) => + JSON.parse(body!).mode === "send" + ? sendQuoteReply + : { status: 400, body: { error: "invalid amount" } }, + ); await renderAndSwapForReserve(); diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx index 635fb54b79..c44627c80a 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.telemetry.test.tsx @@ -19,6 +19,7 @@ import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/us 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 SwapQuote from "popup/helpers/swapQuote"; jest.mock("helpers/metrics", () => ({ ...jest.requireActual("helpers/metrics"), @@ -109,6 +110,17 @@ describe("SwapAmount telemetry + quote-expired surfacing", () => { jest .spyOn(XlmReserve, "shouldShowXlmReservePreflight") .mockReturnValue(false); + jest + .spyOn(SwapQuote, "getSwapQuote") + .mockImplementation(async (params) => ({ + route: { + sourceAmount: params.amount, + destinationAmount: "10", + path: [], + }, + quotes: null, + key: SwapQuote.getSwapQuoteKey(params), + })); }); afterEach(() => { @@ -237,6 +249,9 @@ describe("SwapAmount telemetry + quote-expired surfacing", () => { goToNext, ); + await waitFor(() => + expect(screen.getByTestId("swap-amount-btn-continue")).toBeEnabled(), + ); await act(async () => { fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); }); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts index 9dd35211b0..706ba5e031 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapCtaState.test.ts @@ -9,6 +9,7 @@ const base: SwapCtaInputs = { insufficientXlmForFees: false, hasNoSwapPath: false, isSwapUnavailable: false, + isQuotePending: false, }; describe("getSwapCtaState", () => { @@ -94,6 +95,27 @@ describe("getSwapCtaState", () => { }); }); + it("shows Review swap disabled and loading while the quote is pending", () => { + expect(getSwapCtaState({ ...base, isQuotePending: true })).toEqual({ + disabled: true, + labelKey: "review", + isLoading: true, + }); + }); + + it("prefers the balance and fee blockers over a pending quote", () => { + expect( + getSwapCtaState({ ...base, isAmountTooHigh: true, isQuotePending: true }), + ).toEqual({ disabled: true, labelKey: "insufficientBalance" }); + expect( + getSwapCtaState({ + ...base, + insufficientXlmForFees: true, + isQuotePending: true, + }), + ).toEqual({ disabled: true, labelKey: "insufficientXlmFees" }); + }); + it("prefers the source-balance error over the fee error", () => { expect( getSwapCtaState({ diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index d5c67cbc03..0b2528d45d 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -23,6 +23,11 @@ import { pickBestNonXlmClassicCanonical, } from "popup/helpers/xlmReserve"; import { InputType } from "helpers/transaction"; +import { + getSwapQuoteKey, + isSameSwapQuoteKey, + SwapQuoteKey, +} from "popup/helpers/swapQuote"; import { getSwapCtaState } from "./swapCtaState"; import { ResolvedSwapAmountData } from "../hooks/useGetSwapAmountData"; @@ -54,6 +59,9 @@ interface GetSwapDerivedDataParams { inputType: InputType; isLiveQuoteLoading: boolean; isSwapUnavailable: boolean; + publicKey: string; + /** Key of the quote behind destinationAmount. */ + swapQuoteKey: SwapQuoteKey | null; // Balances-derived "this swap adds a trustline", checked against the // UNFILTERED balances (a hidden held asset needs no new trustline). Drives // the reserve math; distinct from destinationIsNonHeld below, which is a @@ -82,6 +90,8 @@ export const getSwapDerivedData = ({ inputType, isLiveQuoteLoading, isSwapUnavailable, + publicKey, + swapQuoteKey, destRequiresTrustline, }: GetSwapDerivedDataParams) => { const sendData = data; @@ -184,12 +194,28 @@ export const getSwapDerivedData = ({ ? new BigNumber(cleanAmount(amount)).gt(0) : new BigNumber(cleanAmount(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. + // The key check covers the render before the live-quote effect sets + // isLiveQuoteLoading; a failed quote also stores its key, so it settles. + const isQuotePending = + !!asset && + !!destinationAsset && + new BigNumber(cleanAmount(amount)).gt(0) && + (isLiveQuoteLoading || + !isSameSwapQuoteKey( + swapQuoteKey, + getSwapQuoteKey({ + mode: "send", + amount, + sourceAsset: asset, + destAsset: destinationAsset, + networkDetails, + publicKey, + }), + )); + const hasNoSwapPath = swapAmountPositive && - !isLiveQuoteLoading && + !isQuotePending && new BigNumber(cleanAmount(destinationAmount || "0")).isZero(); // Non-XLM swaps pay the network fee from the separate XLM balance; block the @@ -217,6 +243,7 @@ export const getSwapDerivedData = ({ insufficientXlmForFees, hasNoSwapPath, isSwapUnavailable, + isQuotePending, }); return { @@ -240,6 +267,7 @@ export const getSwapDerivedData = ({ isAmountTooHigh, xlmSpendableForFees, insufficientXlmForFees, + isQuotePending, cta, }; }; diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts index 249a99281a..8fcca0f70d 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts @@ -19,6 +19,7 @@ export interface SwapCtaInputs { insufficientXlmForFees: boolean; hasNoSwapPath: boolean; isSwapUnavailable: boolean; + isQuotePending: boolean; } export const getSwapCtaState = ({ @@ -30,7 +31,12 @@ export const getSwapCtaState = ({ insufficientXlmForFees, hasNoSwapPath, isSwapUnavailable, -}: SwapCtaInputs): { disabled: boolean; labelKey: SwapCtaLabelKey } => { + isQuotePending, +}: SwapCtaInputs): { + disabled: boolean; + labelKey: SwapCtaLabelKey; + isLoading?: boolean; +} => { // 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. @@ -53,6 +59,9 @@ export const getSwapCtaState = ({ if (insufficientXlmForFees) { return { disabled: true, labelKey: "insufficientXlmFees" }; } + if (isQuotePending) { + return { disabled: true, labelKey: "review", isLoading: true }; + } if (hasNoSwapPath && isSwapUnavailable) { return { disabled: true, labelKey: "unavailable" }; } diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts index 8c68da9bd2..04304c3490 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts @@ -7,7 +7,7 @@ import { AppDispatch } from "popup/App"; import { saveSwapBestPath } from "popup/ducks/transactionSubmission"; import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; import { cleanAmount } from "popup/helpers/formatters"; -import { getSwapQuote } from "popup/helpers/swapQuote"; +import { getSwapQuote, getSwapQuoteKey } from "popup/helpers/swapQuote"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -88,16 +88,17 @@ export const useSwapLiveQuote = ({ networkDetails: net, publicKey: account, } = liveQuoteArgsRef.current; + const quoteParams = { + mode: "send" as const, + amount: quoteAmount, + sourceAsset: src, + destAsset: dst, + networkDetails: net, + publicKey: account, + }; (async () => { try { - const { route, quotes, key } = await getSwapQuote({ - mode: "send", - amount: quoteAmount, - sourceAsset: src, - destAsset: dst, - networkDetails: net, - publicKey: account, - }); + const { route, quotes, key } = await getSwapQuote(quoteParams); if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; // superseded by a newer quote, or frozen for review } @@ -122,7 +123,13 @@ export const useSwapLiveQuote = ({ e instanceof SwapQuotesError && e.kind === "unavailable", ); // No path / bad input / network error: clear the stale amount. - dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); + dispatch( + saveSwapBestPath({ + path: [], + destinationAmount: "0", + key: getSwapQuoteKey(quoteParams), + }), + ); } })(); }, LIVE_QUOTE_DEBOUNCE_MS), diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 9a4302226c..921e817167 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -419,6 +419,7 @@ export const SwapAmount = ({ bestNonXlmClassicCanonical, canSwapForReserve, isAmountTooHigh, + isQuotePending, cta, } = getSwapDerivedData({ data, @@ -435,6 +436,8 @@ export const SwapAmount = ({ inputType, isLiveQuoteLoading, isSwapUnavailable, + publicKey, + swapQuoteKey: transactionData.swapQuoteKey, destRequiresTrustline, }); @@ -584,7 +587,9 @@ export const SwapAmount = ({ isFullWidth isRounded variant="secondary" - isLoading={simulationState.state === RequestState.LOADING} + isLoading={ + simulationState.state === RequestState.LOADING || cta.isLoading + } disabled={ cta.disabled || (cta.labelKey === "enter" && isSellInputFocused) } @@ -798,6 +803,7 @@ export const SwapAmount = ({ code: dstAsset ? dstAsset.code : "", })} isAmountTooHigh={false} + isAmountLoading={isQuotePending} isReadOnly autoFocus={false} cryptoDecimals={7} From df135e203cd54edfb55317a54bf7051b39eff670 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 21:04:09 -0500 Subject: [PATCH 06/15] fix(swap): derive quote-pending from the stored key, not a loading flag Pressing Review committed the cleaned amount ("1,000" -> "1000") to Redux, which re-armed the live-quote effect after cancelLiveQuote had run. The effect set isLiveQuoteLoading, the request's result landed while the review sheet was open and was dropped as review-frozen, and nothing reset the flag. Cancelling the review then left the CTA disabled with a spinner and the receive amount shimmering until the user edited an input. The stored quote key already answers "is a quote pending": every success and every failure stores the key it answered, and a dropped or superseded response stores nothing. Drop the flag and derive pending from the key alone, so no cancelled or frozen request can leave the screen pending. The CTA-gate test that reaches the review sheet now spreads the full transactionSubmission initial state: the review reads hardwareWalletData and crashed on the partial store, which the test only survived because the sheet rendered after RTL cleanup. Awaiting the click exposed it on the unfixed code too. The redundant /swap/quotes request on each Review press remains; it is harmless now that its result cannot leave the screen pending. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/SwapAmount.ctaGate.test.tsx | 9 ++- .../__tests__/SwapAmount.liveQuote.test.tsx | 62 ++++++++++++++++++- .../SwapAmount/helpers/getSwapDerivedData.ts | 30 +++++---- .../swap/SwapAmount/hooks/useSwapLiveQuote.ts | 16 ++--- .../components/swap/SwapAmount/index.tsx | 25 ++++---- 5 files changed, 98 insertions(+), 44 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx index 597e66f987..799c13e4b2 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.ctaGate.test.tsx @@ -12,6 +12,7 @@ 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"; @@ -315,7 +316,11 @@ describe("SwapAmount CTA gate", () => { state={ { transactionSubmission: { + // The review sheet renders here, and it reads more of the slice + // than transactionData. + ...transactionSubmissionInitialState, transactionData: { + ...transactionSubmissionInitialState.transactionData, asset: "native", amount: "5", destinationAsset: @@ -342,7 +347,9 @@ describe("SwapAmount CTA gate", () => { , ); await waitForQuote(); - fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); + await act(async () => { + 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.liveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx index f0dcd35513..53f7b14af0 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -338,9 +338,8 @@ describe("SwapAmount live receive-amount quote", () => { expectQuoteLoading(); }); - it("shows loading when the stored quote is for other inputs, before the live-quote flag flips", () => { + it("shows loading when the stored quote is for other inputs, with no quote in flight", () => { jest.spyOn(UseSwapLiveQuote, "useSwapLiveQuote").mockReturnValue({ - isLiveQuoteLoading: false, isSwapUnavailable: false, cancelLiveQuote: jest.fn(), }); @@ -408,6 +407,65 @@ describe("SwapAmount live receive-amount quote", () => { expect(fetchSimulation).not.toHaveBeenCalled(); }); + it("re-enables Review swap after the review is cancelled", async () => { + // Review commits the cleaned amount ("1,000" -> "1000") to Redux, which + // re-arms the live quote; its result lands while the review is open and + // is dropped as frozen. The CTA must settle on the stored quote, which is + // for the same inputs, rather than stay pending. + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { + ...swapData, + userBalances: { + balances: [ + { + ...nativeBalance, + total: new BigNumber("5000"), + available: new BigNumber("5000"), + }, + ], + }, + }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } 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); + renderSwapAmount({ amount: "1,000", destinationAsset: AQUA }); + await settle(); + expect(cta()).toBeEnabled(); + + await act(async () => { + fireEvent.click(cta()); + }); + const review = screen.getByRole("dialog", { name: "Review swap" }); + expect(within(review).getByText("Cancel")).toBeInTheDocument(); + // Let any re-armed live quote fire and resolve while the review is open. + await settle(); + + await act(async () => { + fireEvent.click(within(review).getByText("Cancel")); + }); + + expect(cta()).toHaveTextContent("Review swap"); + expect(cta()).toBeEnabled(); + expect(cta().querySelector(".Loader")).toBeNull(); + expect( + within(screen.getByTestId("swap-receive-card")).queryByTestId( + "amount-card-amount-loading", + ), + ).toBeNull(); + }); + it("quotes a percentage of a tiny balance as a plain decimal", async () => { jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ state: { diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index 0b2528d45d..a722455e47 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -57,7 +57,6 @@ interface GetSwapDerivedDataParams { blockaidOverrideState?: string | null; networkDetails: NetworkDetails; inputType: InputType; - isLiveQuoteLoading: boolean; isSwapUnavailable: boolean; publicKey: string; /** Key of the quote behind destinationAmount. */ @@ -88,7 +87,6 @@ export const getSwapDerivedData = ({ blockaidOverrideState, networkDetails, inputType, - isLiveQuoteLoading, isSwapUnavailable, publicKey, swapQuoteKey, @@ -194,24 +192,24 @@ export const getSwapDerivedData = ({ ? new BigNumber(cleanAmount(amount)).gt(0) : new BigNumber(cleanAmount(amountUsd)).gt(0); - // The key check covers the render before the live-quote effect sets - // isLiveQuoteLoading; a failed quote also stores its key, so it settles. + // Pending means the stored quote is not for these inputs. A failed quote + // also stores its key, so it settles; a dropped or superseded response + // stores nothing, so it stays pending until the newer one lands. const isQuotePending = !!asset && !!destinationAsset && new BigNumber(cleanAmount(amount)).gt(0) && - (isLiveQuoteLoading || - !isSameSwapQuoteKey( - swapQuoteKey, - getSwapQuoteKey({ - mode: "send", - amount, - sourceAsset: asset, - destAsset: destinationAsset, - networkDetails, - publicKey, - }), - )); + !isSameSwapQuoteKey( + swapQuoteKey, + getSwapQuoteKey({ + mode: "send", + amount, + sourceAsset: asset, + destAsset: destinationAsset, + networkDetails, + publicKey, + }), + ); const hasNoSwapPath = swapAmountPositive && diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts index 04304c3490..b43c9db539 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts @@ -40,9 +40,10 @@ interface UseSwapLiveQuoteParams { * the debounced callback stays stable and quote results don't re-trigger the * effect (which would loop). * - * Returns isLiveQuoteLoading so the CTA can tell "still loading a quote" apart - * from "no path exists", and isSwapUnavailable when the backend can't quote at - * all. All path results flow through Redux (saveSwapBestPath). + * Returns isSwapUnavailable when the backend can't quote at all. All path + * results flow through Redux (saveSwapBestPath), keyed by the inputs they + * answer; "still loading" is the stored key not matching the current inputs, + * so a dropped or superseded response cannot leave the screen pending. */ export const useSwapLiveQuote = ({ amount, @@ -55,7 +56,6 @@ export const useSwapLiveQuote = ({ swapAmountData, dispatch, }: UseSwapLiveQuoteParams) => { - const [isLiveQuoteLoading, setIsLiveQuoteLoading] = useState(false); const [isSwapUnavailable, setIsSwapUnavailable] = useState(false); const liveQuoteReqRef = useRef(0); @@ -102,9 +102,6 @@ export const useSwapLiveQuote = ({ if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; // superseded by a newer quote, or frozen for review } - // Stop signalling "loading" so the CTA can distinguish a missing - // path from a pending one. - setIsLiveQuoteLoading(false); setIsSwapUnavailable(false); dispatch( saveSwapBestPath({ @@ -118,7 +115,6 @@ export const useSwapLiveQuote = ({ if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; } - setIsLiveQuoteLoading(false); setIsSwapUnavailable( e instanceof SwapQuotesError && e.kind === "unavailable", ); @@ -163,12 +159,10 @@ export const useSwapLiveQuote = ({ const cryptoAmount = 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); setIsSwapUnavailable(false); debouncedQuote.cancel(); liveQuoteReqRef.current += 1; @@ -190,5 +184,5 @@ export const useSwapLiveQuote = ({ networkDetails.networkUrl, ]); - return { isLiveQuoteLoading, isSwapUnavailable, cancelLiveQuote }; + return { isSwapUnavailable, cancelLiveQuote }; }; diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 921e817167..870eb79b53 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -345,19 +345,17 @@ export const SwapAmount = ({ const sellInputRef = useRef(null); // Live "You receive" quote as the user types (debounced path-only lookup). - // isLiveQuoteLoading lets the CTA tell "loading a quote" apart from "no path". - const { isLiveQuoteLoading, isSwapUnavailable, cancelLiveQuote } = - useSwapLiveQuote({ - amount, - asset, - destinationAsset, - destinationAmount, - networkDetails, - publicKey, - isReviewingTx, - swapAmountData, - dispatch, - }); + const { isSwapUnavailable, cancelLiveQuote } = useSwapLiveQuote({ + amount, + asset, + destinationAsset, + destinationAmount, + networkDetails, + publicKey, + isReviewingTx, + swapAmountData, + dispatch, + }); if (isLoading) { return ; @@ -434,7 +432,6 @@ export const SwapAmount = ({ blockaidOverrideState, networkDetails, inputType, - isLiveQuoteLoading, isSwapUnavailable, publicKey, swapQuoteKey: transactionData.swapQuoteKey, From caa8e84759c04265646dd4b170419b1a6f62ed6c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 21:08:48 -0500 Subject: [PATCH 07/15] fix(swap): report quote failures from user actions, not the typing loop getSwapQuotes and getSwapQuote captured to Sentry on every failure, and the live quote re-fires once per debounced keystroke, so an outage cost one event per 500ms of typing per user. Meanwhile a 400 was reported nowhere: the helpers skipped it as "user input", and the reserve prefill skipped every typed error on the claim that the helpers had already reported it. Move reporting to the two paths where the user acted: the review re-quote in useSimulateTxData and the swap-for-reserve prefill. Both report once per press and include badRequest, since the extension builds every field of the request and a 400 is a client bug. The helpers now only type the error; the live hook stays silent. Tests: the helper and classic-outage tests flip to "not reported"; the live-quote tests assert no capture on 400, 5xx, unreachable and classic outage; the review and prefill tests assert one capture each, 400 included. Both suites now clear the Sentry module mock between tests, which the review 400 assertion needed to be discriminating. Co-Authored-By: Claude Fable 5.1 --- @shared/api/helpers/__tests__/getSwapQuotes.test.ts | 7 ++++--- @shared/api/helpers/getSwapQuotes.ts | 6 ++---- .../__tests__/SwapAmount.liveQuote.test.tsx | 13 +++++++++++++ .../__tests__/SwapAmount.reserveQuote.test.tsx | 9 ++++++--- .../__tests__/useSimulateSwapData.quote.test.tsx | 13 ++++++++++++- .../swap/SwapAmount/hooks/useSimulateSwapData.tsx | 6 ++++++ .../src/popup/components/swap/SwapAmount/index.tsx | 10 +++------- .../src/popup/helpers/__tests__/swapQuote.test.ts | 4 ++-- extension/src/popup/helpers/swapQuote.ts | 8 ++++---- 9 files changed, 52 insertions(+), 24 deletions(-) diff --git a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts index 64bf0227db..10b0c24ace 100644 --- a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts +++ b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts @@ -102,16 +102,17 @@ describe("getSwapQuotes", () => { const err = await getSwapQuotes(request).catch((e) => e); expect(err).toBeInstanceOf(SwapQuotesError); expect(err.kind).toBe("unavailable"); - expect(captureException).toHaveBeenCalledTimes(1); }); - it("reports a 5xx to Sentry but not a 400", async () => { + it("never reports to Sentry itself; the user-initiated callers do", async () => { mockedFetch.mockResolvedValueOnce({ status: 503, body: {} }); await getSwapQuotes(request).catch(() => undefined); mockedFetch.mockResolvedValueOnce({ status: 400, body: {} }); await getSwapQuotes(request).catch(() => undefined); + mockedFetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await getSwapQuotes(request).catch(() => undefined); - expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).not.toHaveBeenCalled(); }); it("rejects a 200 without a routes list as swaps unavailable", async () => { diff --git a/@shared/api/helpers/getSwapQuotes.ts b/@shared/api/helpers/getSwapQuotes.ts index 168e8f5a9c..a09ca866bf 100644 --- a/@shared/api/helpers/getSwapQuotes.ts +++ b/@shared/api/helpers/getSwapQuotes.ts @@ -1,5 +1,3 @@ -import { captureException } from "@sentry/browser"; - import { SwapQuoteMode, SwapQuotesResponse } from "../types/backend-api"; import { fetchBackendV2 } from "./fetchBackendV2"; import { redactErrorBody } from "./redactErrorBody"; @@ -41,7 +39,6 @@ export const getSwapQuotes = async ({ })); } catch (e) { const message = `Failed to reach swap quotes - ${e instanceof Error ? e.message : String(e)}`; - captureException(message); throw new SwapQuotesError("unavailable", message); } @@ -50,11 +47,12 @@ export const getSwapQuotes = async ({ return data; } + // Not reported to Sentry here: the live quote re-fires per keystroke, so + // the user-initiated callers (review, reserve prefill) report instead. const message = `Failed to fetch swap quotes - ${status}: ${redactErrorBody(body)}`; // A 400 is the input (e.g. an unfunded account), not an outage. if (status === 400) { throw new SwapQuotesError("badRequest", message); } - captureException(message); throw new SwapQuotesError("unavailable", message); }; 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 index 53f7b14af0..7bcb8a217b 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { render, act, screen, within, fireEvent } from "@testing-library/react"; import BigNumber from "bignumber.js"; +import { captureException } from "@sentry/browser"; import { RequestState } from "constants/request"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; @@ -18,6 +19,11 @@ import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; +jest.mock("@sentry/browser", () => ({ + ...jest.requireActual("@sentry/browser"), + captureException: jest.fn(), +})); + const nativeBalance = { token: { type: "native", code: "XLM" }, total: new BigNumber("100"), @@ -132,6 +138,7 @@ describe("SwapAmount live receive-amount quote", () => { jest.runOnlyPendingTimers(); jest.useRealTimers(); jest.restoreAllMocks(); + jest.clearAllMocks(); }); const settle = () => @@ -224,6 +231,7 @@ describe("SwapAmount live receive-amount quote", () => { expect(fetchBackendV2).toHaveBeenCalled(); expect(cta()).toHaveTextContent("No quote available"); + expect(captureException).not.toHaveBeenCalled(); }); it.each([502, 503])( @@ -236,6 +244,9 @@ describe("SwapAmount live receive-amount quote", () => { expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); expect(cta()).toBeDisabled(); + // One event per debounced keystroke would flood Sentry during an + // outage; the review path reports when the user acts. + expect(captureException).not.toHaveBeenCalled(); }, ); @@ -246,6 +257,7 @@ describe("SwapAmount live receive-amount quote", () => { await settle(); expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); + expect(captureException).not.toHaveBeenCalled(); }); it("shows swaps as temporarily unavailable when classic errored", async () => { @@ -263,6 +275,7 @@ describe("SwapAmount live receive-amount quote", () => { await settle(); expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); + expect(captureException).not.toHaveBeenCalled(); }); it("drops a quote that lands after the screen unmounts", async () => { diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx index 0d83b04139..95029eb78e 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx @@ -202,7 +202,7 @@ describe("SwapAmount swap-for-reserve quote", () => { await waitFor(() => expect(captureException).toHaveBeenCalledWith( - "Swap quotes: classic source timeout", + "Swap-for-reserve prefill failed - Swap quotes: classic source timeout", ), ); expect( @@ -210,7 +210,7 @@ describe("SwapAmount swap-for-reserve quote", () => { ).toBe("5"); }); - it("does not report a rejected receive quote to Sentry again", async () => { + it("reports a rejected receive quote to Sentry once", async () => { fetchBackendV2.mockImplementation(async ({ body }) => JSON.parse(body!).mode === "send" ? sendQuoteReply @@ -224,6 +224,9 @@ describe("SwapAmount swap-for-reserve quote", () => { body: expect.stringContaining('"mode":"receive"'), }), ); - expect(captureException).not.toHaveBeenCalled(); + await waitFor(() => expect(captureException).toHaveBeenCalledTimes(1)); + expect(captureException).toHaveBeenCalledWith( + expect.stringMatching(/^Swap-for-reserve prefill failed - .* - 400: /), + ); }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx index 0c778aa7f7..3c4a62e2bd 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { act, renderHook } from "@testing-library/react"; import { Asset, TransactionBuilder } from "stellar-sdk"; +import { captureException } from "@sentry/browser"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; @@ -11,6 +12,10 @@ import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; import { useSimulateTxData } from "../useSimulateSwapData"; +jest.mock("@sentry/browser", () => ({ + ...jest.requireActual("@sentry/browser"), + captureException: jest.fn(), +})); jest.mock("@shared/api/helpers/stellarSdkServer", () => ({ stellarSdkServer: () => ({ loadAccount: async (pk: string) => { @@ -121,7 +126,10 @@ describe("useSimulateTxData quote when no live quote exists", () => { .spyOn(HorizonGetBestPath, "horizonGetBestPath") .mockRejectedValue(new Error("Horizon must not be called")); }); - afterEach(() => jest.restoreAllMocks()); + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); it("builds the path payment from the backend's classic route", async () => { fetchBackendV2.mockResolvedValue({ status: 200, body: { data: quotes } }); @@ -163,6 +171,7 @@ describe("useSimulateTxData quote when no live quote exists", () => { expect(result.current.state.error).toBe( "Swaps are temporarily unavailable", ); + expect(captureException).toHaveBeenCalledTimes(1); }); it("shows the live CTA's no-quote message when the re-quote is a 400", async () => { @@ -177,6 +186,8 @@ describe("useSimulateTxData quote when no live quote exists", () => { }); expect(result.current.state.error).toBe("No quote available"); + // The extension built this request, so a 400 is a client bug to see. + expect(captureException).toHaveBeenCalledTimes(1); }); it("reports no path when the response has no classic route", async () => { diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 27d119d546..26de5075af 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -1,6 +1,7 @@ import { useReducer, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import BigNumber from "bignumber.js"; +import { captureException } from "@sentry/browser"; import { Asset, BASE_FEE, @@ -304,6 +305,11 @@ function useSimulateTxData({ result = await getSwapQuote(quoteParams); } catch (e) { if (e instanceof SwapQuotesError) { + // Reported here, not in the quote helpers, so an outage costs one + // event per Review press rather than one per keystroke. A 400 is + // included: the extension built the request, so it is a client + // bug, not user input. + captureException(e.message); throw new Error( e.kind === "unavailable" ? ERROR_TO_DISPLAY.SWAPS_UNAVAILABLE diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 870eb79b53..c3b8882e39 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -74,7 +74,6 @@ import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; import { getSwapQuote } from "popup/helpers/swapQuote"; -import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote"; import { EditSlippage } from "./EditSlippage"; @@ -512,12 +511,9 @@ export const SwapAmount = ({ } } catch (e) { // No path / network error — leave the amount as-is for manual entry. - // getSwapQuotes already reported its own failures. - if (!(e instanceof SwapQuotesError)) { - captureException( - `Swap-for-reserve prefill failed - ${JSON.stringify(e)}`, - ); - } + captureException( + `Swap-for-reserve prefill failed - ${e instanceof Error ? e.message : JSON.stringify(e)}`, + ); } }; diff --git a/extension/src/popup/helpers/__tests__/swapQuote.test.ts b/extension/src/popup/helpers/__tests__/swapQuote.test.ts index 552a38c03d..ae35fd1de1 100644 --- a/extension/src/popup/helpers/__tests__/swapQuote.test.ts +++ b/extension/src/popup/helpers/__tests__/swapQuote.test.ts @@ -224,7 +224,7 @@ describe("getSwapQuote", () => { }; it.each(["error", "timeout", "rate_limited"])( - "reports classic %s as swaps unavailable, to Sentry", + "reports classic %s as swaps unavailable, leaving Sentry to the caller", async (reason) => { const err = await quoteWith([{ source: "classic", reason }]).catch( (e) => e, @@ -232,7 +232,7 @@ describe("getSwapQuote", () => { expect(err).toBeInstanceOf(GetSwapQuotes.SwapQuotesError); expect(err.kind).toBe("unavailable"); - expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).not.toHaveBeenCalled(); }, ); diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index ae85c53f4c..b975343c09 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -1,4 +1,3 @@ -import { captureException } from "@sentry/browser"; import BigNumber from "bignumber.js"; import { Horizon, StrKey } from "stellar-sdk"; @@ -183,9 +182,10 @@ export const getSwapQuote = async ( u.source === "classic" && CLASSIC_OUTAGE_REASONS.includes(u.reason), ); if (outage) { - const message = `Swap quotes: classic source ${outage.reason}`; - captureException(message); - throw new SwapQuotesError("unavailable", message); + throw new SwapQuotesError( + "unavailable", + `Swap quotes: classic source ${outage.reason}`, + ); } } return { route: route ? routeFromBackend(route) : null, quotes, key }; From e5b8d5ce81003c6bfe16079928d8199109a96c74 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 21:35:47 -0500 Subject: [PATCH 08/15] refactor(swap): drop the dispatcher-less Horizon getBestPath thunk Every quote now goes through getSwapQuote, which calls horizonGetBestPath directly for custom networks; nothing dispatches the thunk, and no test references it. Its fulfilled/rejected reducers still wrote transactionData.path and destinationAmount, so a new caller could have bypassed the backend silently. Remove the thunk, the two reducers, and the two imports only they used. Co-Authored-By: Claude Fable 5.1 --- .../src/popup/ducks/transactionSubmission.ts | 58 +------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index fe4b124457..d306e4df13 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -34,9 +34,8 @@ import { BlockaidWarning, SecurityLevel } from "popup/constants/blockaid"; import { SwapSource } from "popup/constants/swap"; import { DEFAULT_TX_TIMEOUT } from "popup/constants/transaction"; -import { encodeSep53Message, getCanonicalFromAsset } from "helpers/stellar"; +import { encodeSep53Message } from "helpers/stellar"; import { INDEXER_URL } from "@shared/constants/mercury"; -import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; import { isQuoteExpiredError } from "popup/helpers/quoteExpiry"; import { soroswapGetBestPath, @@ -393,35 +392,6 @@ export const getSoroswapTokens = createAsyncThunk< return tokenData.assets; }); -// returns the full record so can save the best path and its rate -export const getBestPath = createAsyncThunk< - Horizon.ServerApi.PaymentPathRecord, - { - amount: string; - sourceAsset: string; - destAsset: string; - networkDetails: NetworkDetails; - }, - { rejectValue: ErrorMessage } ->( - "getBestPath", - async ({ amount, sourceAsset, destAsset, networkDetails }, thunkApi) => { - try { - return await horizonGetBestPath({ - amount, - sourceAsset, - destAsset, - networkDetails, - }); - } catch (e) { - const message = e instanceof Error ? e.message : JSON.stringify(e); - return thunkApi.rejectWithValue({ - errorMessage: message, - }); - } - }, -); - export const getBestSoroswapPath = createAsyncThunk< { amountIn?: string; @@ -824,11 +794,6 @@ const transactionSubmissionSlice = createSlice({ state.error = action.payload; }, ); - builder.addCase(getBestPath.rejected, (state) => { - state.transactionData.path = initialState.transactionData.path; - state.transactionData.destinationAmount = - initialState.transactionData.destinationAmount; - }); builder.addCase(getBestSoroswapPath.rejected, (state) => { state.transactionData.path = initialState.transactionData.path; state.transactionData.destinationAmount = @@ -842,27 +807,6 @@ const transactionSubmissionSlice = createSlice({ soroswapTokens, }; }); - builder.addCase(getBestPath.fulfilled, (state, action) => { - if (!action.payload) { - state.transactionData.path = []; - state.transactionData.destinationAmount = ""; - return; - } - - // store in canonical form for easier use - const path: string[] = []; - action.payload.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)); - } - }); - - state.transactionData.path = path; - state.transactionData.destinationAmount = - action.payload.destination_amount; - }); builder.addCase(getBestSoroswapPath.fulfilled, (state, action) => { if (!action.payload) { state.transactionData.path = []; From d2974dcb431540897ed0d096b85d9a5f91f79be2 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 21:53:53 -0500 Subject: [PATCH 09/15] fix(swap): report live-quote outages once per screen, stop Review re-arming The previous commit moved Sentry reporting to the review re-quote, but a live-quote failure disables the CTA, so that path is unreachable during an outage and nothing was reported at all. Report from the live hook instead, once per mount until a quote succeeds, so an outage costs one event per screen visit rather than one per keystroke. 400s stay unreported there: on the live path they are the user's state (unfunded account, unsupported token); the review and prefill paths still report them. Key the live-quote effect on the cleaned amount. Review commits "1,000" as "1000", which re-armed a quote that could land behind the review sheet and replace the path the XDR was built from, and cost a redundant request per press. Align the two 400 comments: a 400 is the request, not an outage; the review comment no longer claims it is always a client bug. Co-Authored-By: Claude Fable 5.1 --- @shared/api/helpers/getSwapQuotes.ts | 6 +-- .../__tests__/SwapAmount.liveQuote.test.tsx | 49 +++++++++++++++---- .../SwapAmount/hooks/useSimulateSwapData.tsx | 6 +-- .../swap/SwapAmount/hooks/useSwapLiveQuote.ts | 26 ++++++++-- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/@shared/api/helpers/getSwapQuotes.ts b/@shared/api/helpers/getSwapQuotes.ts index a09ca866bf..d696748fa3 100644 --- a/@shared/api/helpers/getSwapQuotes.ts +++ b/@shared/api/helpers/getSwapQuotes.ts @@ -47,10 +47,10 @@ export const getSwapQuotes = async ({ return data; } - // Not reported to Sentry here: the live quote re-fires per keystroke, so - // the user-initiated callers (review, reserve prefill) report instead. + // Not reported to Sentry here: callers decide, since the live quote + // re-fires per keystroke and dedupes its own reports. const message = `Failed to fetch swap quotes - ${status}: ${redactErrorBody(body)}`; - // A 400 is the input (e.g. an unfunded account), not an outage. + // A 400 is the request (bad input, an unfunded account), not an outage. if (status === 400) { throw new SwapQuotesError("badRequest", message); } 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 index 7bcb8a217b..99f91a7653 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -244,12 +244,42 @@ describe("SwapAmount live receive-amount quote", () => { expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); expect(cta()).toBeDisabled(); - // One event per debounced keystroke would flood Sentry during an - // outage; the review path reports when the user acts. - expect(captureException).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledTimes(1); }, ); + it("reports an outage once per screen, not once per keystroke", async () => { + fetchBackendV2.mockResolvedValue({ status: 503, body: { error: "x" } }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + await settle(); + expect(captureException).toHaveBeenCalledTimes(1); + + await act(async () => { + getTestStore()!.dispatch(saveAmount("6")); + }); + await settle(); + await act(async () => { + getTestStore()!.dispatch(saveAmount("7")); + }); + await settle(); + expect(fetchBackendV2).toHaveBeenCalledTimes(3); + expect(captureException).toHaveBeenCalledTimes(1); + + // A recovery re-arms the report, so a second outage is seen. + fetchBackendV2.mockResolvedValueOnce(quotesReply([classicRoute])); + await act(async () => { + getTestStore()!.dispatch(saveAmount("8")); + }); + await settle(); + expect(cta()).toBeEnabled(); + await act(async () => { + getTestStore()!.dispatch(saveAmount("9")); + }); + await settle(); + expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); + expect(captureException).toHaveBeenCalledTimes(2); + }); + it("shows swaps as temporarily unavailable when the backend is unreachable", async () => { fetchBackendV2.mockRejectedValue(new TypeError("Failed to fetch")); renderSwapAmount({ amount: "5", destinationAsset: AQUA }); @@ -257,7 +287,7 @@ describe("SwapAmount live receive-amount quote", () => { await settle(); expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); - expect(captureException).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledTimes(1); }); it("shows swaps as temporarily unavailable when classic errored", async () => { @@ -275,7 +305,7 @@ describe("SwapAmount live receive-amount quote", () => { await settle(); expect(cta()).toHaveTextContent("Swaps are temporarily unavailable"); - expect(captureException).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledTimes(1); }); it("drops a quote that lands after the screen unmounts", async () => { @@ -421,10 +451,10 @@ describe("SwapAmount live receive-amount quote", () => { }); it("re-enables Review swap after the review is cancelled", async () => { - // Review commits the cleaned amount ("1,000" -> "1000") to Redux, which - // re-arms the live quote; its result lands while the review is open and - // is dropped as frozen. The CTA must settle on the stored quote, which is - // for the same inputs, rather than stay pending. + // Review commits the cleaned amount ("1,000" -> "1000") to Redux. That + // must not re-quote (the stored quote is for the same inputs), and the + // CTA must settle on the stored quote after a cancel rather than stay + // pending. jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ state: { state: RequestState.SUCCESS, @@ -469,6 +499,7 @@ describe("SwapAmount live receive-amount quote", () => { fireEvent.click(within(review).getByText("Cancel")); }); + expect(fetchBackendV2).toHaveBeenCalledTimes(1); expect(cta()).toHaveTextContent("Review swap"); expect(cta()).toBeEnabled(); expect(cta().querySelector(".Loader")).toBeNull(); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 26de5075af..a7a1d74ecf 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -305,10 +305,8 @@ function useSimulateTxData({ result = await getSwapQuote(quoteParams); } catch (e) { if (e instanceof SwapQuotesError) { - // Reported here, not in the quote helpers, so an outage costs one - // event per Review press rather than one per keystroke. A 400 is - // included: the extension built the request, so it is a client - // bug, not user input. + // Reported per Review press, 400 included: the request was built + // from the extension's own state, so a rejection is worth seeing. captureException(e.message); throw new Error( e.kind === "unavailable" diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts index b43c9db539..fc099f7f4d 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { debounce } from "lodash"; import BigNumber from "bignumber.js"; +import { captureException } from "@sentry/browser"; import { NetworkDetails } from "@shared/constants/stellar"; import { AppDispatch } from "popup/App"; @@ -59,6 +60,10 @@ export const useSwapLiveQuote = ({ const [isSwapUnavailable, setIsSwapUnavailable] = useState(false); const liveQuoteReqRef = useRef(0); + // Last failure reported to Sentry this mount. The quote re-fires per + // debounced keystroke, so an outage is reported once until a quote + // succeeds, not once per keystroke. + const lastReportedRef = useRef(null); const liveQuoteArgsRef = useRef({ asset, destinationAsset, @@ -102,6 +107,7 @@ export const useSwapLiveQuote = ({ if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; // superseded by a newer quote, or frozen for review } + lastReportedRef.current = null; setIsSwapUnavailable(false); dispatch( saveSwapBestPath({ @@ -115,6 +121,15 @@ export const useSwapLiveQuote = ({ if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; } + // A 400 here is the user's state (an unfunded account, an + // unsupported token); the review path reports those. + if (!(e instanceof SwapQuotesError && e.kind === "badRequest")) { + const message = e instanceof Error ? e.message : String(e); + if (message !== lastReportedRef.current) { + lastReportedRef.current = message; + captureException(message); + } + } setIsSwapUnavailable( e instanceof SwapQuotesError && e.kind === "unavailable", ); @@ -146,6 +161,12 @@ export const useSwapLiveQuote = ({ [debouncedQuote], ); + // The crypto amount is the source of truth in both display modes (the fiat + // input keeps it in sync), so quote it directly. Keyed on the cleaned value + // so Review committing "1,000" as "1000" is not a change and cannot re-arm + // a quote behind the review sheet. + const cryptoAmount = cleanAmount(amount || "0"); + useEffect(() => { if ( swapAmountData.state !== RequestState.SUCCESS || @@ -154,9 +175,6 @@ export const useSwapLiveQuote = ({ ) { return; } - // The crypto amount is the source of truth in both display modes (the fiat - // input keeps it in sync), so quote it directly. - const cryptoAmount = cleanAmount(amount || "0"); if (new BigNumber(cryptoAmount || "0").isGreaterThan(0)) { debouncedQuote(cryptoAmount); @@ -175,7 +193,7 @@ export const useSwapLiveQuote = ({ } // 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, + cryptoAmount, asset, destinationAsset, swapAmountData.state, From 74a6b56aa5d55a055303ebef496e3f8bc9b0a4eb Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 22:22:11 -0500 Subject: [PATCH 10/15] feat(swap): Quote unavailable line and sheet on the amount screen Shown when the CTA state is noQuote or unavailable; tapping opens a bottom sheet explaining that no quote could be fetched (Figma 3405-86973). Moved here from the C4 branch, which also adds the See details link that shares the SwapAsset__details-link style; the style now lands with this commit. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.quoteUnavailable.test.tsx | 200 ++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 39 ++++ .../components/swap/SwapAmount/styles.scss | 35 +++ .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + 5 files changed, 278 insertions(+) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx new file mode 100644 index 0000000000..0d40183847 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx @@ -0,0 +1,200 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +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 UseSwapLiveQuote from "popup/components/swap/SwapAmount/hooks/useSwapLiveQuote"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as Internal from "@shared/api/internal"; +import * as TokenList from "@shared/api/helpers/token-list"; +import { getSwapQuoteKey } from "popup/helpers/swapQuote"; + +// Interpolates, so the fee inside "{{fee}} XLM network fee" is checkable. +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + key.replace(/{{(\w+)}}/g, (_: string, name: string) => + String(opts?.[name] ?? `{{${name}}}`), + ), + }), +})); + +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "PUBLIC" }, + icons: {}, + userBalances: { + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, + }, + ], + }, + tokenPrices: { native: { currentPrice: "0.3" } }, +}; + +const swapQuotes = { + routes: [ + { + source: "lifi", + fromAmount: "5", + toAmount: "1.85", + fees: [], + paths: [ + { + share: "1", + hops: [{ venue: "Aquarius", from: "XLM", to: USDC, pool: "p1" }], + }, + ], + }, + ], + unavailable: [], +}; + +const quoteKey = (amount: string, destAsset = USDC) => + getSwapQuoteKey({ + mode: "send", + amount, + sourceAsset: "native", + destAsset, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: "G123", + }); + +const renderSwapAmount = (transactionData: Record) => + render( + + + , + ); + +const BODY = + "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later."; + +const mockLiveQuote = (state: Record) => + jest.spyOn(UseSwapLiveQuote, "useSwapLiveQuote").mockReturnValue({ + isSwapUnavailable: false, + cancelLiveQuote: jest.fn(), + ...state, + } as any); + +describe("SwapAmount quote unavailable line", () => { + 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(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + mockLiveQuote({}); + jest.spyOn(Internal, "getDiscoverData").mockResolvedValue([]); + jest.spyOn(TokenList, "getCombinedAssetListData").mockResolvedValue([]); + }); + + afterEach(() => jest.restoreAllMocks()); + + const noQuote = { destinationAmount: "0", swapQuotes: null }; + + it("shows for the no-quote CTA state", () => { + renderSwapAmount(noQuote); + expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent( + "No quote available", + ); + expect(screen.getByTestId("swap-quote-unavailable")).toHaveTextContent( + "Quote unavailable", + ); + }); + + it("shows for the swaps-unavailable CTA state", () => { + mockLiveQuote({ isSwapUnavailable: true }); + renderSwapAmount(noQuote); + expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent( + "Swaps are temporarily unavailable", + ); + expect(screen.getByTestId("swap-quote-unavailable")).toBeInTheDocument(); + }); + + it("is hidden while a quote is loading", () => { + // The stored quote is for another amount, so one for "5" is pending. + renderSwapAmount({ ...noQuote, swapQuoteKey: quoteKey("4") }); + expect(screen.queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden when a quote exists", () => { + renderSwapAmount({}); + expect(screen.queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden when the amount is empty", () => { + renderSwapAmount({ ...noQuote, amount: "" }); + expect(screen.queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("opens the sheet with the title and body, and closes it", async () => { + renderSwapAmount(noQuote); + expect(screen.queryByText(BODY)).toBeNull(); + fireEvent.click(screen.getByTestId("swap-quote-unavailable")); + expect(screen.getByText(BODY)).toBeInTheDocument(); + expect( + screen.getByTestId("QuoteUnavailableSheet__title"), + ).toHaveTextContent("Quote unavailable"); + fireEvent.click(screen.getByTestId("QuoteUnavailableSheet__close")); + await waitFor(() => expect(screen.queryByText(BODY)).toBeNull()); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index c3b8882e39..5eb1445f57 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -74,6 +74,8 @@ import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; import { getSwapQuote } from "popup/helpers/swapQuote"; +import { SheetHeader } from "popup/components/SheetHeader"; +import { SheetLayout } from "popup/components/SheetLayout"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote"; import { EditSlippage } from "./EditSlippage"; @@ -226,6 +228,7 @@ export const SwapAmount = ({ }); }, [isReviewingTx]); const [isXlmReserveOpen, setIsXlmReserveOpen] = useState(false); + const [isQuoteUnavailableOpen, setIsQuoteUnavailableOpen] = useState(false); // Tracks focus on the sell input so the "Enter an amount" CTA can disable // itself while the input is focused. The extension has no virtual keyboard, // so once the input is focused the tap-to-focus affordance is redundant. @@ -812,6 +815,19 @@ export const SwapAmount = ({ }} />
+ {cta.labelKey === "noQuote" || + cta.labelKey === "unavailable" ? ( +
+ +
+ ) : null}
)} + setIsQuoteUnavailableOpen(false)} + isModalOpen={isQuoteUnavailableOpen} + ariaLabel={t("Quote unavailable")} + > + {isQuoteUnavailableOpen ? ( + + setIsQuoteUnavailableOpen(false)} + closeTestId="QuoteUnavailableSheet__close" + titleTestId="QuoteUnavailableSheet__title" + /> +
+ {t( + "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.", + )} +
+
+ ) : ( + <> + )} +
setIsXlmReserveOpen(false)} isModalOpen={isXlmReserveOpen} diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index 701cea2899..630e40927f 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -307,3 +307,38 @@ margin-bottom: 2rem; } } + +.SwapAsset__quote-unavailable-body { + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + line-height: pxToRem(20px); +} + +.SwapAsset__details-link { + display: flex; + align-items: center; + justify-content: center; + gap: pxToRem(6px); + margin-top: pxToRem(12px); + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + line-height: pxToRem(18px); + + button { + display: inline-flex; + align-items: center; + gap: pxToRem(4px); + padding: 0; + border: none; + background: none; + color: var(--sds-clr-gray-12); + font: inherit; + font-weight: var(--sds-fw-medium); + cursor: pointer; + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + } + } +} diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index b12c8218e3..fe80184379 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -509,6 +509,7 @@ "Proceed with caution": "Proceed with caution", "Protocol details": "Protocol details", "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", + "Quote unavailable": "Quote unavailable", "Rate": "Rate", "Read before importing your key": "Read before importing your key", "Ready to migrate": "Ready to migrate", @@ -828,6 +829,7 @@ "was swapped to": "was swapped to", "wasm": "wasm", "Wasm Hash": "Wasm Hash", + "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.": "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.", "We were unable to scan this site for security issues": "We were unable to scan this site for security issues", "We were unable to scan this token for security threats": "We were unable to scan this token for security threats", "WEBSITE CONNECTION IS NOT SECURE": "WEBSITE CONNECTION IS NOT SECURE", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 05f16bfd8c..d618f15067 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -509,6 +509,7 @@ "Proceed with caution": "Prossiga com cautela", "Protocol details": "Detalhes do protocolo", "Quote has expired, please try again to get a new quote": "A cotação expirou, tente novamente para obter uma nova cotação", + "Quote unavailable": "Cotação indisponível", "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", "Ready to migrate": "Pronto para migrar", @@ -828,6 +829,7 @@ "was swapped to": "foi trocado para", "wasm": "wasm", "Wasm Hash": "Hash Wasm", + "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.": "Não foi possível obter uma cotação para esta troca agora. Tente outro valor ou outro par de tokens, ou tente novamente mais tarde.", "We were unable to scan this site for security issues": "Não foi possível verificar este site quanto a problemas de segurança", "We were unable to scan this token for security threats": "Não foi possível verificar este token quanto a ameaças de segurança", "WEBSITE CONNECTION IS NOT SECURE": "CONEXÃO DO WEBSITE NÃO É SEGURA", From bbfb50777666ecf244dfe613a5ba639b9f58a2c7 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 22:31:16 -0500 Subject: [PATCH 11/15] fix(swap): close the Quote unavailable sheet once a quote arrives Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.quoteUnavailable.test.tsx | 35 +++++++++++++++++-- .../components/swap/SwapAmount/index.tsx | 11 ++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx index 0d40183847..4fd6db338d 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx @@ -1,12 +1,21 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { AppDataType } from "helpers/hooks/useGetAppData"; -import { Wrapper } from "popup/__testHelpers__"; -import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { getTestStore, Wrapper } from "popup/__testHelpers__"; +import { + initialState as transactionSubmissionInitialState, + saveSwapBestPath, +} 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"; @@ -197,4 +206,24 @@ describe("SwapAmount quote unavailable line", () => { fireEvent.click(screen.getByTestId("QuoteUnavailableSheet__close")); await waitFor(() => expect(screen.queryByText(BODY)).toBeNull()); }); + + it("closes the sheet when a quote arrives", async () => { + renderSwapAmount(noQuote); + fireEvent.click(screen.getByTestId("swap-quote-unavailable")); + expect(screen.getByText(BODY)).toBeInTheDocument(); + + act(() => { + getTestStore()!.dispatch( + saveSwapBestPath({ + path: [], + destinationAmount: "1.85", + quotes: swapQuotes as any, + key: quoteKey("5"), + }), + ); + }); + + expect(screen.queryByTestId("swap-quote-unavailable")).toBeNull(); + await waitFor(() => expect(screen.queryByText(BODY)).toBeNull()); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 5eb1445f57..555cd67647 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -440,6 +440,14 @@ export const SwapAmount = ({ destRequiresTrustline, }); + const isQuoteUnavailable = + cta.labelKey === "noQuote" || cta.labelKey === "unavailable"; + // Close the sheet once a quote arrives (set during render: cta is derived + // below the early returns, where an effect can't go). + if (isQuoteUnavailableOpen && !isQuoteUnavailable) { + setIsQuoteUnavailableOpen(false); + } + const handleSwapForReserve = async () => { const sellCanonical = sourceIsNonXlmClassic ? asset @@ -815,8 +823,7 @@ export const SwapAmount = ({ }} />
- {cta.labelKey === "noQuote" || - cta.labelKey === "unavailable" ? ( + {isQuoteUnavailable ? (