diff --git a/@shared/api/helpers/__tests__/getSwapQuotes.test.ts b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts new file mode 100644 index 0000000000..10b0c24ace --- /dev/null +++ b/@shared/api/helpers/__tests__/getSwapQuotes.test.ts @@ -0,0 +1,125 @@ +import { captureException } from "@sentry/browser"; + +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, 429])( + "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("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"); + }); + + 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).not.toHaveBeenCalled(); + }); + + 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..d696748fa3 --- /dev/null +++ b/@shared/api/helpers/getSwapQuotes.ts @@ -0,0 +1,58 @@ +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 => { + 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)}`; + throw new SwapQuotesError("unavailable", message); + } + + const data = (body as { data?: SwapQuotesResponse } | null)?.data; + if (status === 200 && Array.isArray(data?.routes)) { + return data; + } + + // 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 request (bad input, an unfunded account), not an outage. + if (status === 400) { + throw new SwapQuotesError("badRequest", 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/e2e-tests/blockaidScan.malicious.test.ts b/extension/e2e-tests/blockaidScan.malicious.test.ts index fb36bd6991..69468af7d9 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); @@ -359,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 79fce8f40d..80598c4b21 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); @@ -276,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 e320778791..0aa797292d 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); @@ -268,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 e3c8b9f2bc..653923fa28 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); @@ -293,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/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 96eb785e9c..dc827158b9 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -1048,6 +1048,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. // @@ -3455,6 +3500,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 7e98e8b817..836ccb3051 100644 --- a/extension/e2e-tests/swap.test.ts +++ b/extension/e2e-tests/swap.test.ts @@ -50,6 +50,7 @@ import { stubScanAssetSafe, stubScanTxMalicious, stubScanTxSafe, + stubSwapQuotes, TESTNET_TRENDING_ASSETS_ROUTE, TRENDING_ASSETS_ROUTE, } from "./helpers/stubs"; @@ -72,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) @@ -118,26 +126,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 }); @@ -156,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({ @@ -213,6 +202,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(); @@ -231,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({ @@ -311,6 +302,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(); @@ -324,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({ @@ -362,28 +355,8 @@ 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: [], - }, - ], - }, - }, - }), - ); + // "No quote available" and never reaches the banner. + await stubSwapQuotes(context, { toAmount: "0.95" }); // Stub scan-tx to return Malicious (stubScanTxMalicious matches "**/scan-tx**") await stubScanTxMalicious(page); }; @@ -402,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). @@ -561,27 +534,7 @@ test("flagged non-held destination stacks trustline and Blockaid banners", async route.fulfill(assetSearchResponse([])), ); // 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 @@ -631,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({ @@ -687,26 +640,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) => @@ -749,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({ @@ -896,26 +830,7 @@ test("recovers to the amount screen when the swap quote expires at submit", asyn await context.route(TRENDING_ASSETS_ROUTE, (route) => route.fulfill(assetSearchResponse([])), ); - 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. @@ -948,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, @@ -962,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..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,11 +12,13 @@ import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { Wrapper } from "popup/__testHelpers__"; +import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; import { SwapAmount } from "popup/components/swap/SwapAmount"; import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as 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 +64,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 +127,7 @@ describe("SwapAmount CTA gate", () => { /> , ); + await waitForQuote(); await act(async () => { fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); }); @@ -297,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: @@ -323,7 +346,10 @@ describe("SwapAmount CTA gate", () => { /> , ); - fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); + await waitForQuote(); + 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.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..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 @@ -9,7 +9,8 @@ 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"; +import * as SwapQuote from "popup/helpers/swapQuote"; const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; const USDC_CANONICAL = `USDC:${USDC_ISSUER}`; @@ -81,9 +82,21 @@ describe("SwapAmount fiat label", () => { isQuoteExpired: false, fetchData: jest.fn().mockResolvedValue(undefined), } as any); + jest.spyOn(FetchBackendV2, "fetchBackendV2").mockResolvedValue({ + status: 200, + body: { data: { routes: [], unavailable: [] } }, + }); jest - .spyOn(HorizonGetBestPath, "horizonGetBestPath") - .mockResolvedValue(null as any); + .spyOn(SwapQuote, "getSwapQuote") + .mockImplementation(async (params) => ({ + route: { + sourceAmount: params.amount, + destinationAmount: "10", + path: [], + }, + quotes: null, + key: SwapQuote.getSwapQuoteKey(params), + })); }); afterEach(() => jest.restoreAllMocks()); @@ -97,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 }); @@ -105,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(); }); @@ -132,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({ @@ -150,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 fc2946c235..8d836520d3 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,17 +1,29 @@ import React from "react"; -import { render, act } from "@testing-library/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"; 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 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"; +jest.mock("@sentry/browser", () => ({ + ...jest.requireActual("@sentry/browser"), + captureException: jest.fn(), +})); + const nativeBalance = { token: { type: "native", code: "XLM" }, total: new BigNumber("100"), @@ -29,12 +41,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,44 +126,439 @@ 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(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); jest.restoreAllMocks(); + jest.clearAllMocks(); }); - 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"); + // The no-quote branch is the only one that shows the Quote unavailable + // line; a disabled CTA alone could also be the pending or balance branch. + const expectQuoteUnavailable = () => { + expect(cta()).toHaveTextContent("Review swap"); + expect(cta()).toBeDisabled(); + expect(screen.getByTestId("swap-quote-unavailable")).toBeInTheDocument(); + }; + + 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 settle(); + + 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", + }), + }); + expect(horizonGetBestPath).not.toHaveBeenCalled(); + }); + + 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(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(); + expect(cta()).toHaveTextContent("Review swap"); + }); + + it("disables Review swap when the response has no classic route", async () => { + fetchBackendV2.mockResolvedValue(quotesReply([lifiRoute])); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(fetchBackendV2).toHaveBeenCalled(); + expectQuoteUnavailable(); + }); + + it("disables Review swap on empty routes", async () => { + fetchBackendV2.mockResolvedValue(quotesReply([])); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(fetchBackendV2).toHaveBeenCalled(); + expectQuoteUnavailable(); + }); + + it("disables Review swap on a 400 without reporting it", async () => { + fetchBackendV2.mockResolvedValue({ + status: 400, + body: { error: "invalid amount" }, + }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expect(fetchBackendV2).toHaveBeenCalled(); + expectQuoteUnavailable(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it.each([502, 503])( + "disables Review swap on a %s and reports it", + async (status) => { + fetchBackendV2.mockResolvedValue({ status, body: { error: "x" } }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expectQuoteUnavailable(); + 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 () => { - jest.advanceTimersByTime(600); + getTestStore()!.dispatch(saveAmount("6")); + }); + await settle(); + await act(async () => { + getTestStore()!.dispatch(saveAmount("7")); }); + await settle(); + expect(fetchBackendV2).toHaveBeenCalledTimes(3); + expect(captureException).toHaveBeenCalledTimes(1); - // Lightweight path lookup (not the full simulation) with the typed amount. - expect(getBestPath).toHaveBeenCalledWith( - expect.objectContaining({ - amount: "5", - sourceAsset: "native", - destAsset: AQUA, + // 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(); + expectQuoteUnavailable(); + expect(captureException).toHaveBeenCalledTimes(2); + }); + + it("disables Review swap when the backend is unreachable", async () => { + fetchBackendV2.mockRejectedValue(new TypeError("Failed to fetch")); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expectQuoteUnavailable(); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it("disables Review swap when classic errored", async () => { + fetchBackendV2.mockResolvedValue({ + status: 200, + body: { + data: { + routes: [lifiRoute], + unavailable: [{ source: "classic", reason: "timeout" }], + }, + }, + }); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + + await settle(); + + expectQuoteUnavailable(); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + 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 }); + await settle(); + expectQuoteUnavailable(); + + await act(async () => { + getTestStore()!.dispatch(saveAmount("6")); + }); + await settle(); + + expect(cta()).toBeEnabled(); + }); + + describe("while the quote for the current inputs is pending", () => { + const pendingQuote = () => { + let resolve: (v: unknown) => void = () => {}; + fetchBackendV2.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + return () => + act(async () => { + resolve(quotesReply([classicRoute])); + }); + }; + 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(); + }); + + it("shows loading when the stored quote is for other inputs, with no quote in flight", () => { + jest.spyOn(UseSwapLiveQuote, "useSwapLiveQuote").mockReturnValue({ + 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", + }, + }); + + expectQuoteLoading(); + }); + + it("enables Review swap once the matching quote lands", async () => { + const resolveQuote = pendingQuote(); + renderSwapAmount({ amount: "5", destinationAsset: AQUA }); + await settle(); + expectQuoteLoading(); + + 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(); + }); + + 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("re-enables Review swap after the review is cancelled", async () => { + // 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, + 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(fetchBackendV2).toHaveBeenCalledTimes(1); + 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: { + 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: "" }); - await act(async () => { - jest.advanceTimersByTime(600); - }); + await settle(); - expect(getBestPath).not.toHaveBeenCalled(); + expect(fetchBackendV2).not.toHaveBeenCalled(); }); }); 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..9f0f6eb0c4 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx @@ -0,0 +1,219 @@ +import React from "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 { 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"; +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({ + 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, with Review swap disabled", () => { + renderSwapAmount(noQuote); + const cta = screen.getByTestId("swap-amount-btn-continue"); + expect(cta).toHaveTextContent("Review swap"); + expect(cta).toBeDisabled(); + expect(screen.getByTestId("swap-quote-unavailable")).toHaveTextContent( + "Quote unavailable", + ); + }); + + 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()); + }); + + 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/__tests__/SwapAmount.reserveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx new file mode 100644 index 0000000000..4cf255dfea --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx @@ -0,0 +1,275 @@ +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + 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"; +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, + saveAsset, +} 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"; + +jest.mock("@sentry/browser", () => ({ + ...jest.requireActual("@sentry/browser"), + captureException: jest.fn(), +})); + +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: [], +}); + +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; + + 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(); + jest.clearAllMocks(); + }); + + const renderAndSwapForReserve = async () => { + render( + + + , + ); + + await waitFor(() => + expect(screen.getByTestId("swap-amount-btn-continue")).toBeEnabled(), + ); + 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")); + }); + }; + + 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", + 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"), + ); + }); + + it("reports a classic outage instead of treating it as no route", async () => { + fetchBackendV2.mockImplementation(async ({ body }) => + JSON.parse(body!).mode === "send" + ? sendQuoteReply + : { + status: 200, + body: { + data: { + routes: [], + unavailable: [{ source: "classic", reason: "timeout" }], + }, + }, + }, + ); + + await renderAndSwapForReserve(); + + await waitFor(() => + expect(captureException).toHaveBeenCalledWith( + "Swap-for-reserve prefill failed - Swap quotes: classic source timeout", + ), + ); + expect( + getTestStore()!.getState().transactionSubmission.transactionData.amount, + ).toBe("5"); + }); + + it("reports a rejected receive quote to Sentry once", async () => { + fetchBackendV2.mockImplementation(async ({ body }) => + JSON.parse(body!).mode === "send" + ? sendQuoteReply + : { status: 400, body: { error: "invalid amount" } }, + ); + + await renderAndSwapForReserve(); + + expect(fetchBackendV2).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('"mode":"receive"'), + }), + ); + await waitFor(() => expect(captureException).toHaveBeenCalledTimes(1)); + expect(captureException).toHaveBeenCalledWith( + expect.stringMatching(/^Swap-for-reserve prefill failed - .* - 400: /), + ); + }); + + it("drops the prefill when the sell token changes while the quote is in flight", async () => { + let resolveReceive: (v: unknown) => void = () => {}; + fetchBackendV2.mockImplementation(async ({ body }) => + JSON.parse(body!).mode === "send" + ? sendQuoteReply + : new Promise((resolve) => { + resolveReceive = resolve; + }), + ); + + await renderAndSwapForReserve(); + await waitFor(() => + expect(fetchBackendV2).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('"mode":"receive"'), + }), + ), + ); + + // The user picks another sell token before the receive quote lands. + await act(async () => { + getTestStore()!.dispatch(saveAsset(AQUA)); + }); + await act(async () => { + resolveReceive({ + status: 200, + body: { + data: { + routes: [route("3.25", "0.5102041", "XLM")], + unavailable: [], + }, + }, + }); + }); + + expect( + getTestStore()!.getState().transactionSubmission.transactionData.amount, + ).toBe("5"); + }); +}); 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 66a533d205..1996d6c6aa 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, + isQuotePending: false, }; describe("getSwapCtaState", () => { @@ -66,10 +67,11 @@ describe("getSwapCtaState", () => { }); }); - it("flags no quote available when the path is empty", () => { + it("keeps Review swap, disabled, when there is no quote (the Quote unavailable line explains)", () => { expect(getSwapCtaState({ ...base, hasNoSwapPath: true })).toEqual({ disabled: true, - labelKey: "noQuote", + labelKey: "review", + isQuoteUnavailable: true, }); }); @@ -80,6 +82,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 7af25efcc6..cadeef2c38 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"; @@ -52,7 +57,9 @@ interface GetSwapDerivedDataParams { blockaidOverrideState?: string | null; networkDetails: NetworkDetails; inputType: InputType; - isLiveQuoteLoading: 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 @@ -79,7 +86,8 @@ export const getSwapDerivedData = ({ blockaidOverrideState, networkDetails, inputType, - isLiveQuoteLoading, + publicKey, + swapQuoteKey, destRequiresTrustline, }: GetSwapDerivedDataParams) => { const sendData = data; @@ -182,12 +190,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. + // 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) && + !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 @@ -214,6 +238,7 @@ export const getSwapDerivedData = ({ isAmountTooHigh, insufficientXlmForFees, hasNoSwapPath, + isQuotePending, }); return { @@ -237,6 +262,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 28a1261b33..77bec2ee45 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapCtaState.ts @@ -6,7 +6,6 @@ export type SwapCtaLabelKey = | "enter" | "insufficientBalance" | "insufficientXlmFees" - | "noQuote" | "review"; export interface SwapCtaInputs { @@ -17,6 +16,7 @@ export interface SwapCtaInputs { isAmountTooHigh: boolean; insufficientXlmForFees: boolean; hasNoSwapPath: boolean; + isQuotePending: boolean; } export const getSwapCtaState = ({ @@ -27,7 +27,13 @@ export const getSwapCtaState = ({ isAmountTooHigh, insufficientXlmForFees, hasNoSwapPath, -}: SwapCtaInputs): { disabled: boolean; labelKey: SwapCtaLabelKey } => { + isQuotePending, +}: SwapCtaInputs): { + disabled: boolean; + labelKey: SwapCtaLabelKey; + isLoading?: boolean; + isQuoteUnavailable?: 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. @@ -50,8 +56,13 @@ export const getSwapCtaState = ({ if (insufficientXlmForFees) { return { disabled: true, labelKey: "insufficientXlmFees" }; } + if (isQuotePending) { + return { disabled: true, labelKey: "review", isLoading: true }; + } + // The label stays "Review swap" (design): the Quote unavailable line and + // sheet explain why it's disabled. if (hasNoSwapPath) { - return { disabled: true, labelKey: "noQuote" }; + return { disabled: true, labelKey: "review", isQuoteUnavailable: true }; } return { disabled: false, labelKey: "review" }; }; 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..b574b0049e --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx @@ -0,0 +1,272 @@ +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"; +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("@sentry/browser", () => ({ + ...jest.requireActual("@sentry/browser"), + captureException: jest.fn(), +})); +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 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({ + 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(); + jest.clearAllMocks(); + }); + + 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", + ); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + 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"); + // The extension built this request, so a 400 is a client bug to see. + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it("shows the same no-quote message as a 400 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 quote available"); + }); + + 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/__tests__/useSimulateSwapData.test.ts b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts index d18d73a999..73931d286f 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts @@ -164,20 +164,20 @@ describe("getSwapErrorMessage", () => { it("returns known error even when assets are contract IDs", () => { const result = getSwapErrorMessage( - new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND), + new Error(ERROR_TO_DISPLAY.NO_QUOTE), contractAsset, classicAsset, ); - expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); + expect(result).toBe(ERROR_TO_DISPLAY.NO_QUOTE); }); it("returns known error message for classic assets", () => { const result = getSwapErrorMessage( - new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND), + new Error(ERROR_TO_DISPLAY.NO_QUOTE), classicAsset, classicAsset, ); - expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); + expect(result).toBe(ERROR_TO_DISPLAY.NO_QUOTE); }); it("returns unknown error for unrecognized Error with classic assets", () => { @@ -193,11 +193,11 @@ describe("getSwapErrorMessage", () => { it("returns known error message for string errors with classic assets", () => { const result = getSwapErrorMessage( - ERROR_TO_DISPLAY.NO_PATH_FOUND, + ERROR_TO_DISPLAY.NO_QUOTE, classicAsset, classicAsset, ); - expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); + expect(result).toBe(ERROR_TO_DISPLAY.NO_QUOTE); }); it("returns unknown error for unrecognized string with classic assets", () => { 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 50bde7d646..a4e9ae7cbe 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, @@ -29,7 +30,12 @@ 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, + 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"; @@ -38,8 +44,9 @@ import { AppDispatch } from "popup/App"; 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", + NO_QUOTE: "No quote available", }; const UNKNOWN_ERROR_DISPLAY = @@ -232,6 +239,8 @@ function useSimulateTxData({ memo, path: storedPath, destinationAmount: storedDestinationAmount, + swapQuotes: storedSwapQuotes, + swapQuoteKey: storedSwapQuoteKey, } = useSelector(transactionDataSelector); const reduxDispatch = useDispatch(); @@ -267,36 +276,54 @@ 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 ( + !isSameSwapQuoteKey(storedSwapQuoteKey, getSwapQuoteKey(quoteParams)) || + !destinationAmount || + new BigNumber(destinationAmount).lte(0) + ) { + let result; + try { + result = await getSwapQuote(quoteParams); + } catch (e) { + if (e instanceof SwapQuotesError) { + // 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" + ? ERROR_TO_DISPLAY.SWAPS_UNAVAILABLE + : ERROR_TO_DISPLAY.NO_QUOTE, + ); + } + throw e; + } - 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, - }); - - if (!bestPath?.destination_amount) { - throw new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND); + if (!result.route) { + throw new Error(ERROR_TO_DISPLAY.NO_QUOTE); } - 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; + key = result.key; } if (destinationRate) { payload.dstAmountPriceUsd = formatAmount( @@ -335,6 +362,8 @@ function useSimulateTxData({ saveSwapBestPath({ 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 f7665e8680..71bc4d969c 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapLiveQuote.ts @@ -1,13 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } 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"; 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, getSwapQuoteKey } from "popup/helpers/swapQuote"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -24,6 +25,7 @@ interface UseSwapLiveQuoteParams { destinationAsset: string; destinationAmount: string; networkDetails: NetworkDetails; + publicKey: string; isReviewingTx: boolean; swapAmountData: SwapAmountDataState; dispatch: AppDispatch; @@ -39,8 +41,9 @@ 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". All path results flow through Redux (saveSwapBestPath). + * 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, @@ -48,15 +51,28 @@ export const useSwapLiveQuote = ({ destinationAsset, destinationAmount, networkDetails, + publicKey, isReviewingTx, swapAmountData, dispatch, }: UseSwapLiveQuoteParams) => { - const [isLiveQuoteLoading, setIsLiveQuoteLoading] = useState(false); - const liveQuoteReqRef = useRef(0); - const liveQuoteArgsRef = useRef({ asset, destinationAsset, networkDetails }); - liveQuoteArgsRef.current = { asset, destinationAsset, networkDetails }; + // 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, + 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,46 +88,52 @@ export const useSwapLiveQuote = ({ asset: src, destinationAsset: dst, 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 bestPath = await horizonGetBestPath({ - amount: quoteAmount, - sourceAsset: src, - destAsset: dst, - networkDetails: net, - }); + const { route, quotes, key } = await getSwapQuote(quoteParams); 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); - 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)); - } - }); + lastReportedRef.current = null; dispatch( saveSwapBestPath({ - path, - destinationAmount: bestPath.destination_amount, + path: route?.path ?? [], + destinationAmount: route?.destinationAmount ?? "0", + quotes, + key, }), ); - } catch { + } catch (e) { if (liveQuoteReqRef.current !== reqId || isReviewingRef.current) { return; } - setIsLiveQuoteLoading(false); - // No path / network error: clear the stale received amount. - dispatch(saveSwapBestPath({ path: [], destinationAmount: "0" })); + // 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); + } + } + // No path / bad input / network error: clear the stale amount. + dispatch( + saveSwapBestPath({ + path: [], + destinationAmount: "0", + key: getSwapQuoteKey(quoteParams), + }), + ); } })(); }, LIVE_QUOTE_DEBOUNCE_MS), @@ -119,7 +141,24 @@ export const useSwapLiveQuote = ({ [], ); - useEffect(() => () => debouncedQuote.cancel(), [debouncedQuote]); + const cancelLiveQuote = useCallback(() => { + debouncedQuote.cancel(); + liveQuoteReqRef.current += 1; + }, [debouncedQuote]); + + useEffect( + () => () => { + debouncedQuote.cancel(); + liveQuoteReqRef.current += 1; + }, + [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 ( @@ -129,17 +168,12 @@ 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)) { - setIsLiveQuoteLoading(true); debouncedQuote(cryptoAmount); } else { // Source amount cleared: cancel any pending/in-flight quote and reset the // received amount so the card shows 0 (skip the dispatch if already 0). - setIsLiveQuoteLoading(false); debouncedQuote.cancel(); liveQuoteReqRef.current += 1; if ( @@ -150,7 +184,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]); + }, [ + cryptoAmount, + asset, + destinationAsset, + swapAmountData.state, + publicKey, + networkDetails.network, + networkDetails.networkUrl, + ]); - return { isLiveQuoteLoading }; + return { cancelLiveQuote }; }; 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/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 806ab9afaa..610c69cf2e 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -73,7 +73,9 @@ 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 { 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. @@ -237,6 +240,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) { @@ -340,15 +345,25 @@ export const SwapAmount = ({ }); const sellInputRef = useRef(null); + // A receive quote for the reserve prefill can take seconds; its answer is + // only applied to the inputs it was asked for. + const reserveQuoteInputsRef = useRef(""); + reserveQuoteInputsRef.current = `${asset}|${publicKey}|${networkDetails.network}`; + useEffect( + () => () => { + reserveQuoteInputsRef.current = ""; + }, + [], + ); // 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 { cancelLiveQuote } = useSwapLiveQuote({ amount, asset, destinationAsset, destinationAmount, networkDetails, + publicKey, isReviewingTx, swapAmountData, dispatch, @@ -414,6 +429,7 @@ export const SwapAmount = ({ bestNonXlmClassicCanonical, canSwapForReserve, isAmountTooHigh, + isQuotePending, cta, } = getSwapDerivedData({ data, @@ -428,10 +444,18 @@ export const SwapAmount = ({ blockaidOverrideState, networkDetails, inputType, - isLiveQuoteLoading, + publicKey, + swapQuoteKey: transactionData.swapQuoteKey, destRequiresTrustline, }); + const isQuoteUnavailable = !!cta.isQuoteUnavailable; + // 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 @@ -468,20 +492,26 @@ export const SwapAmount = ({ const reserveTarget = new BigNumber(BASE_RESERVE) .dividedBy(1 - slippageFraction) .toFixed(7); - const path = await horizonGetBestReceivePath({ - destinationAmount: reserveTarget, + const quotedFor = reserveQuoteInputsRef.current; + const { route } = await getSwapQuote({ + mode: "receive", + amount: reserveTarget, sourceAsset: sellCanonical, destAsset: "native", networkDetails, + publicKey, }); - if (path?.source_amount) { + if (reserveQuoteInputsRef.current !== quotedFor) { + return; // the sell token, account or network changed meanwhile + } + 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))); @@ -505,7 +535,7 @@ 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)}`, + `Swap-for-reserve prefill failed - ${e instanceof Error ? e.message : JSON.stringify(e)}`, ); } }; @@ -515,7 +545,6 @@ export const SwapAmount = ({ enter: t("Enter an amount"), insufficientBalance: t("Insufficient balance"), insufficientXlmFees: t("Not enough XLM for network fees"), - noQuote: t("No quote available"), review: t("Review swap"), }; @@ -572,7 +601,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) } @@ -685,7 +716,7 @@ export const SwapAmount = ({ ? new BigNumber(cleanAmount(usd)) .dividedBy(assetPrice) .decimalPlaces(assetDecimals) - .toString() + .toFixed() : DEFAULT_AMOUNT; formik.setFieldValue("amount", crypto); dispatch(saveAmount(crypto)); @@ -786,6 +817,7 @@ export const SwapAmount = ({ code: dstAsset ? dstAsset.code : "", })} isAmountTooHigh={false} + isAmountLoading={isQuotePending} isReadOnly autoFocus={false} cryptoDecimals={7} @@ -801,6 +833,18 @@ export const SwapAmount = ({ }} />
+ {isQuoteUnavailable ? ( +
+ +
+ ) : 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/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 894e671295..d306e4df13 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -25,6 +25,8 @@ import { SoroswapToken, } 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"; @@ -32,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, @@ -391,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; @@ -538,6 +510,10 @@ 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; + swapQuoteKey: SwapQuoteKey | null; } interface HardwareWalletData { @@ -602,6 +578,8 @@ export const initialState: InitialState = { isSoroswap: false, manualTransactionFee: null, swapSource: null, + swapQuotes: null, + swapQuoteKey: null, }, transactionSimulation: { response: null, @@ -741,10 +719,20 @@ 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; + 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, @@ -764,6 +752,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; @@ -803,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 = @@ -821,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 = []; 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..650693d22f --- /dev/null +++ b/extension/src/popup/helpers/__tests__/swapQuote.test.ts @@ -0,0 +1,390 @@ +import { + MAINNET_NETWORK_DETAILS, + 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"; +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(); + jest.clearAllMocks(); + }); + + 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("sends a contract token whose symbol contains a colon as its contract ID", async () => { + getSwapQuotes.mockResolvedValue({ routes: [], unavailable: [] }); + + await getSwapQuote({ + mode: "send", + amount: "1", + sourceAsset: `MY:TOKEN:${TOKEN_CONTRACT}`, + destAsset: "native", + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(getSwapQuotes).toHaveBeenCalledWith( + expect.objectContaining({ fromAsset: TOKEN_CONTRACT, toAsset: "XLM" }), + ); + }); + + it("keeps a hop asset whose symbol contains a colon as one canonical", async () => { + const hopAsset = `MY:TOKEN:${TOKEN_CONTRACT}`; + getSwapQuotes.mockResolvedValue({ + routes: [ + { + ...classicRoute, + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: "XLM", to: hopAsset }, + { venue: "stellar-dex", from: hopAsset, to: USDC }, + ], + }, + ], + }, + ], + unavailable: [], + }); + + const result = await getSwapQuote({ + mode: "send", + amount: "100", + sourceAsset: "native", + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(result.route?.path).toEqual([hopAsset]); + }); + + 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, + key: expect.any(Object), + }); + }); + + 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, key: expect.any(Object) }); + }); + }); + + 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 }); + 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, leaving Sentry to the caller", + async (reason) => { + const err = await quoteWith([{ source: "classic", reason }]).catch( + (e) => e, + ); + + expect(err).toBeInstanceOf(GetSwapQuotes.SwapQuotesError); + expect(err.kind).toBe("unavailable"); + expect(captureException).not.toHaveBeenCalled(); + }, + ); + + 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(); + }); + }); + + 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, + key: expect.any(Object), + }); + }); + + 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, + key: expect.any(Object), + }); + }); + }); +}); diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts new file mode 100644 index 0000000000..e62b83784f --- /dev/null +++ b/extension/src/popup/helpers/swapQuote.ts @@ -0,0 +1,193 @@ +import BigNumber from "bignumber.js"; +import { Horizon, StrKey } from "stellar-sdk"; + +import { + getSwapQuotes, + SwapQuotesError, +} 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, + splitCanonical, +} 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[]; +} + +/** 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, + // The backend accepts only plain decimals: no "5.", ".5" or exponent. + amount: new BigNumber(cleanAmount(amount)).toFixed(), +}); + +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) => + quotes.routes.find((route) => route.source === "classic"); + +const toBackendAsset = (canonical: string) => { + if (isNativeAssetId(canonical)) { + return "XLM"; + } + const { issuer } = splitCanonical(canonical); + return StrKey.isValidContract(issuer) ? issuer : canonical; +}; + +const fromBackendAsset = (asset: string) => { + const { code, issuer } = splitCanonical(asset); + 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 ( + params: SwapQuoteParams, +): Promise => { + const { mode, sourceAsset, destAsset, networkDetails, publicKey } = params; + const key = getSwapQuoteKey(params); + const { amount } = key; + 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, key }; + } + + const quotes = await getSwapQuotes({ + network: networkDetails.network, + account: publicKey, + mode, + fromAsset: toBackendAsset(sourceAsset), + toAsset: toBackendAsset(destAsset), + amount, + }); + const route = pickSwapRoute(quotes); + if (!route) { + const outage = quotes.unavailable.find( + (u) => + u.source === "classic" && CLASSIC_OUTAGE_REASONS.includes(u.reason), + ); + if (outage) { + throw new SwapQuotesError( + "unavailable", + `Swap quotes: classic source ${outage.reason}`, + ); + } + } + return { route: route ? routeFromBackend(route) : null, quotes, key }; +}; diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 49486a7acb..016fc1c731 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -12,7 +12,6 @@ const swapKeys = [ "Insufficient balance", "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", "Not enough XLM for network fees", - "No quote available", "The token you're receiving was flagged as malicious by Blockaid.", "The token you're receiving was flagged as suspicious by Blockaid.", "The token you're receiving couldn't be scanned for security risks.", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 77c1501390..cd9f48c03b 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -447,7 +447,6 @@ "No hidden tokens": "No hidden tokens", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", "No price data for this period": "No price data for this period", - "No quote available": "No quote available", "No tokens match {{term}}": "No tokens match {{term}}", "No transactions to show": "No transactions to show", "None": "None", @@ -509,6 +508,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", @@ -827,6 +827,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 c5d4d0c4b4..09e0451173 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -447,7 +447,6 @@ "No hidden tokens": "Nenhum token oculto", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", "No price data for this period": "Sem dados de preço para este período", - "No quote available": "Nenhuma cotação disponível", "No tokens match {{term}}": "Nenhum token corresponde a {{term}}", "No transactions to show": "Nenhuma transação para mostrar", "None": "Nenhum", @@ -509,6 +508,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", @@ -827,6 +827,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",