Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0a5a9ad
feat(swap): add backend-v2 swap quote client
piyalbasu Nov 4, 2026
bad1536
feat(swap): quote from the backend in the live quote and reserve sheet
piyalbasu Nov 4, 2026
058571e
fix(swap): key stored quotes to their request; show outages as unavai…
piyalbasu Nov 5, 2026
7702894
fix(swap): plain-decimal quote amounts; review and reserve-sheet quot…
piyalbasu Nov 5, 2026
a6491f5
Merge origin/master into feat/swap-backend-quotes
piyalbasu Nov 9, 2026
6f50255
fix(swap): show quote loading and gate Review until the quote lands
piyalbasu Nov 9, 2026
df135e2
fix(swap): derive quote-pending from the stored key, not a loading flag
piyalbasu Nov 11, 2026
caa8e84
fix(swap): report quote failures from user actions, not the typing loop
piyalbasu Nov 11, 2026
e5b8d5c
refactor(swap): drop the dispatcher-less Horizon getBestPath thunk
piyalbasu Nov 11, 2026
d2974dc
fix(swap): report live-quote outages once per screen, stop Review re-…
piyalbasu Nov 11, 2026
74a6b56
feat(swap): Quote unavailable line and sheet on the amount screen
piyalbasu Nov 11, 2026
bbfb507
fix(swap): close the Quote unavailable sheet once a quote arrives
piyalbasu Nov 11, 2026
56ebd3b
Merge branch 'master' into feat/swap-backend-quotes
piyalbasu Nov 11, 2026
aa5e039
fix(swap): keep "Review swap" on the CTA when there is no quote
piyalbasu Nov 11, 2026
e010e95
fix(swap): show "No quote available" when the review re-quote has no …
piyalbasu Nov 11, 2026
0c9b324
fix(swap): send a contract token with a colon in its symbol as its co…
piyalbasu Nov 11, 2026
772b767
fix(swap): drop a reserve prefill quote whose inputs changed in flight
piyalbasu Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions @shared/api/helpers/__tests__/getSwapQuotes.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
58 changes: 58 additions & 0 deletions @shared/api/helpers/getSwapQuotes.ts
Original file line number Diff line number Diff line change
@@ -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<SwapQuotesResponse> => {
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);
};
40 changes: 40 additions & 0 deletions @shared/api/types/backend-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
33 changes: 6 additions & 27 deletions extension/e2e-tests/blockaidScan.malicious.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
37 changes: 7 additions & 30 deletions extension/e2e-tests/blockaidScan.safe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
stubTokenPrices,
stubScanAssetSafe,
stubScanTxSafe,
createAssetObject,
stubAssetSearch,
stubSwapQuotes,
} from "./helpers/stubs";
import { goToAddAsset } from "./helpers/assets";

Expand Down Expand Up @@ -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);
Expand All @@ -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,
});

Expand Down
Loading
Loading