From b803399d2035cacf1bf054ac837af694a72be5fc Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 4 Nov 2026 18:31:42 -0500 Subject: [PATCH 01/15] feat(swap): quote swaps from backend-v2 instead of Horizon (C1) The main swap flow and the XLM reserve sheet now quote through POST /api/v1/swap/quotes on the JWT-signed freighterBackendV2 client (send quotes while typing, a receive quote for the 0.5 XLM estimate). No Horizon path-finding call remains in the swap flow. Until C2 moves building to /swap/tx, the client still builds a classic path payment, so it uses the response's classic route (selectSwapRoute) and treats its absence as "no path". The full response is kept in the swap store as swapQuotes for C2/C4. Status handling: 200 with no usable route and 400 show the existing "no path found" error; 502/503 show the new "Swaps are temporarily unavailable" (spec section 8). Other failures keep the existing generic error. Quotes now take seconds (LI.FI 1.7-3.3 s measured 2026-10 vs Horizon ~0.35 s, per the spec), so a stale response could overwrite a newer quote or a cleared amount; findSwapPath now drops responses superseded by a later request, clearPath or resetSwap. Also corrects two comments that still said the amount came from Horizon. Co-Authored-By: Claude Opus 5.5 --- .../helpers/selectSwapRoute.test.ts | 67 +++++ .../hooks/useSwapForXlmReserve.test.ts | 148 +++++++++++ __tests__/ducks/swap.findSwapPath.test.ts | 235 ++++++++++++++++++ __tests__/services/backend.swapQuotes.test.ts | 103 ++++++++ .../screens/SwapScreen/helpers/index.ts | 4 + .../SwapScreen/helpers/selectSwapRoute.ts | 17 ++ .../SwapScreen/helpers/swapDisplayStrings.ts | 2 +- .../SwapScreen/hooks/useSwapForXlmReserve.ts | 34 +-- .../SwapScreen/screens/SwapAmountScreen.tsx | 1 + src/config/types.ts | 43 ++++ src/ducks/swap.ts | 175 +++++++------ src/hooks/useTokenFiatConverter/reducer.ts | 2 +- src/i18n/locales/en/translations.json | 1 + src/i18n/locales/pt/translations.json | 1 + src/services/backend.ts | 38 +++ 15 files changed, 764 insertions(+), 107 deletions(-) create mode 100644 __tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts create mode 100644 __tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts create mode 100644 __tests__/ducks/swap.findSwapPath.test.ts create mode 100644 __tests__/services/backend.swapQuotes.test.ts create mode 100644 src/components/screens/SwapScreen/helpers/selectSwapRoute.ts diff --git a/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts new file mode 100644 index 000000000..ef3284638 --- /dev/null +++ b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts @@ -0,0 +1,67 @@ +import { + selectSwapRoute, + swapRouteIntermediateAssets, +} from "components/screens/SwapScreen/helpers/selectSwapRoute"; +import { SwapQuotes, SwapRoute } from "config/types"; + +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + +const route = (source: string, toAmount: string): SwapRoute => ({ + source, + fromAmount: "100", + toAmount, + paths: [{ share: "1", hops: [{ venue: "v", from: "XLM", to: USDC }] }], + fees: [], +}); + +describe("selectSwapRoute", () => { + it("picks the classic route even when another source ranks first", () => { + const classic = route("classic", "17"); + const quotes: SwapQuotes = { + routes: [route("lifi", "18"), classic], + unavailable: [], + }; + + expect(selectSwapRoute(quotes)).toBe(classic); + }); + + it("returns null when no classic route was quoted", () => { + expect( + selectSwapRoute({ routes: [route("lifi", "18")], unavailable: [] }), + ).toBeNull(); + }); + + it("returns null for empty routes", () => { + expect( + selectSwapRoute({ + routes: [], + unavailable: [{ source: "classic", reason: "no_route" }], + }), + ).toBeNull(); + }); +}); + +describe("swapRouteIntermediateAssets", () => { + it("returns the assets between the first hop's source and the last hop's destination", () => { + const multiHop: SwapRoute = { + ...route("classic", "17"), + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: USDC, to: "XLM" }, + { venue: "stellar-dex", from: "XLM", to: AQUA }, + { venue: "stellar-dex", from: AQUA, to: "EURC:GDHU" }, + ], + }, + ], + }; + + expect(swapRouteIntermediateAssets(multiHop)).toEqual(["XLM", AQUA]); + }); + + it("returns no intermediates for a direct hop", () => { + expect(swapRouteIntermediateAssets(route("classic", "17"))).toEqual([]); + }); +}); diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts new file mode 100644 index 000000000..4dd0bc2ff --- /dev/null +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts @@ -0,0 +1,148 @@ +import { act, renderHook } from "@testing-library/react-hooks"; +import BigNumber from "bignumber.js"; +import { useSwapForXlmReserve } from "components/screens/SwapScreen/hooks/useSwapForXlmReserve"; +import { NETWORKS } from "config/constants"; +import { TokenTypeWithCustomToken } from "config/types"; +import { type HeldBalanceItem } from "hooks/useBalancesList"; +import { fetchSwapQuotes } from "services/backend"; + +jest.mock("services/backend", () => ({ + fetchSwapQuotes: jest.fn(), +})); + +jest.mock("helpers/balances", () => ({ + calculateSpendableAmount: jest.fn(() => { + const MockBigNumber = + jest.requireActual("bignumber.js").default; + return new MockBigNumber("1000"); + }), +})); + +jest.mock("config/logger", () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, +})); + +const mockFetchSwapQuotes = fetchSwapQuotes as jest.MockedFunction< + typeof fetchSwapQuotes +>; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC = `USDC:${USDC_ISSUER}`; +const ACCOUNT = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; + +const usdcBalance = { + id: USDC, + tokenCode: "USDC", + tokenType: TokenTypeWithCustomToken.CREDIT_ALPHANUM4, + token: { + code: "USDC", + issuer: { key: USDC_ISSUER }, + type: "credit_alphanum4", + }, + total: new BigNumber("100"), + available: new BigNumber("100"), +} as unknown as HeldBalanceItem; + +const renderReserveHook = () => { + const setSourceToken = jest.fn(); + const setDestinationToken = jest.fn(); + const setTokenAmount = jest.fn(); + const { result } = renderHook(() => + useSwapForXlmReserve({ + sourceBalance: usdcBalance, + bestNonXlmClassicBalance: undefined, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, + subentryCount: 0, + swapFee: "0.00001", + setSourceToken, + setDestinationToken, + setTokenAmount, + }), + ); + return { result, setSourceToken, setDestinationToken, setTokenAmount }; +}; + +describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("asks the backend for a receive quote of 0.5 XLM paid in the sell token", async () => { + mockFetchSwapQuotes.mockResolvedValue({ routes: [], unavailable: [] }); + const { result } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(mockFetchSwapQuotes).toHaveBeenCalledWith({ + network: NETWORKS.PUBLIC, + account: ACCOUNT, + mode: "receive", + fromAsset: USDC, + toAsset: "XLM", + amount: "0.5", + }); + }); + + it("pre-fills the classic route's fromAmount", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [ + { + source: "classic", + fromAmount: "0.0912345", + toAmount: "0.5", + paths: [ + { + share: "1", + hops: [{ venue: "stellar-dex", from: USDC, to: "XLM" }], + }, + ], + fees: [], + }, + ], + unavailable: [], + }); + const { result, setTokenAmount } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(setTokenAmount).toHaveBeenCalledWith("0.0912345"); + }); + + it("leaves the amount alone when no classic route is quoted", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [], + unavailable: [{ source: "classic", reason: "no_route" }], + }); + const { result, setTokenAmount, setDestinationToken } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(setTokenAmount).not.toHaveBeenCalled(); + expect(setDestinationToken).toHaveBeenCalled(); + }); + + it("still flips the receive side to XLM when the backend fails", async () => { + mockFetchSwapQuotes.mockRejectedValue({ + message: "Request failed with status code 503", + status: 503, + isNetworkError: false, + }); + const { result, setTokenAmount, setDestinationToken } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(setTokenAmount).not.toHaveBeenCalled(); + expect(setDestinationToken).toHaveBeenCalledWith( + expect.objectContaining({ id: "XLM" }), + ); + }); +}); diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts new file mode 100644 index 000000000..f06e0d02e --- /dev/null +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -0,0 +1,235 @@ +import BigNumber from "bignumber.js"; +import { NETWORKS } from "config/constants"; +import { PricedBalance, SwapQuotes, SwapRoute } from "config/types"; +import { useSwapStore } from "ducks/swap"; +import { fetchSwapQuotes } from "services/backend"; + +jest.mock("services/backend", () => ({ + fetchSwapQuotes: jest.fn(), +})); + +jest.mock("i18next", () => ({ + t: (key: string) => key, +})); + +jest.mock("config/logger", () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, +})); + +const mockFetchSwapQuotes = fetchSwapQuotes as jest.MockedFunction< + typeof fetchSwapQuotes +>; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC = `USDC:${USDC_ISSUER}`; +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; +const ACCOUNT = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; + +const xlmBalance = { + id: "XLM", + tokenCode: "XLM", + token: { code: "XLM", type: "native" }, + total: new BigNumber("100"), + available: new BigNumber("100"), +} as unknown as PricedBalance; + +const usdcBalance = { + id: USDC, + tokenCode: "USDC", + token: { + code: "USDC", + issuer: { key: USDC_ISSUER }, + type: "credit_alphanum4", + }, + total: new BigNumber("0"), + available: new BigNumber("0"), +} as unknown as PricedBalance; + +const classicRoute: SwapRoute = { + source: "classic", + fromAmount: "10", + toAmount: "2.0000000", + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: "XLM", to: AQUA }, + { venue: "stellar-dex", from: AQUA, to: USDC }, + ], + }, + ], + fees: [], +}; + +const lifiRoute: SwapRoute = { + source: "lifi", + fromAmount: "10", + toAmount: "2.1000000", + paths: [ + { + share: "1", + hops: [{ venue: "Aquarius", from: "XLM", to: USDC, pool: "abc" }], + }, + ], + fees: [], +}; + +const find = (sourceAmount = "10") => + useSwapStore.getState().findSwapPath({ + sourceBalance: xlmBalance, + destinationBalance: usdcBalance, + sourceAmount, + slippage: 1, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, + }); + +const apiError = (status: number) => ({ + message: `Request failed with status code ${status}`, + status, + isNetworkError: false, +}); + +describe("useSwapStore.findSwapPath — backend quotes", () => { + beforeEach(() => { + jest.clearAllMocks(); + useSwapStore.getState().resetSwap(); + }); + + it("requests a send quote for the wire assets, amount, account and network", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [classicRoute], + unavailable: [], + }); + + await find(); + + expect(mockFetchSwapQuotes).toHaveBeenCalledWith({ + network: NETWORKS.PUBLIC, + account: ACCOUNT, + mode: "send", + fromAsset: "XLM", + toAsset: USDC, + amount: "10", + }); + }); + + it("builds the path result from the classic route, not the first-ranked route", async () => { + const quotes: SwapQuotes = { + routes: [lifiRoute, classicRoute], + unavailable: [], + }; + mockFetchSwapQuotes.mockResolvedValue(quotes); + + await find(); + + const state = useSwapStore.getState(); + expect(state.pathError).toBeNull(); + expect(state.isLoadingPath).toBe(false); + expect(state.pathResult).toEqual({ + sourceAmount: "10", + destinationAmount: "2.0000000", + destinationAmountMin: "1.9800000", + path: [AQUA], + conversionRate: "0.2000000", + }); + expect(state.destinationAmount).toBe("2.0000000"); + expect(state.swapQuotes).toEqual(quotes); + }); + + it("shows no path when the backend returns empty routes", async () => { + const quotes: SwapQuotes = { + routes: [], + unavailable: [{ source: "classic", reason: "no_route" }], + }; + mockFetchSwapQuotes.mockResolvedValue(quotes); + + await find(); + + const state = useSwapStore.getState(); + expect(state.pathResult).toBeNull(); + expect(state.pathError).toBe("swapScreen.errors.noPathFound"); + expect(state.swapQuotes).toEqual(quotes); + }); + + it("shows no path when only a non-classic route was quoted", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [lifiRoute], + unavailable: [], + }); + + await find(); + + const state = useSwapStore.getState(); + expect(state.pathResult).toBeNull(); + expect(state.pathError).toBe("swapScreen.errors.noPathFound"); + }); + + it("treats a 400 as bad input (no path), not as an outage", async () => { + mockFetchSwapQuotes.mockRejectedValue(apiError(400)); + + await find(); + + expect(useSwapStore.getState().pathError).toBe( + "swapScreen.errors.noPathFound", + ); + }); + + it.each([502, 503])( + "shows swaps temporarily unavailable on %i", + async (status) => { + mockFetchSwapQuotes.mockRejectedValue(apiError(status)); + + await find(); + + const state = useSwapStore.getState(); + expect(state.pathResult).toBeNull(); + expect(state.pathError).toBe("swapScreen.errors.swapsUnavailable"); + }, + ); + + it("ignores a response that lands after a newer request was started", async () => { + let resolveFirst: (q: SwapQuotes) => void = () => {}; + mockFetchSwapQuotes + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ + routes: [{ ...classicRoute, fromAmount: "20", toAmount: "4.0000000" }], + unavailable: [], + }); + + const first = find("10"); + await find("20"); + resolveFirst({ routes: [classicRoute], unavailable: [] }); + await first; + + const state = useSwapStore.getState(); + expect(state.destinationAmount).toBe("4.0000000"); + expect(state.pathResult?.sourceAmount).toBe("20"); + }); + + it("ignores an in-flight response after clearPath", async () => { + let resolveFirst: (q: SwapQuotes) => void = () => {}; + mockFetchSwapQuotes.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + const first = find("10"); + useSwapStore.getState().clearPath(); + resolveFirst({ routes: [classicRoute], unavailable: [] }); + await first; + + const state = useSwapStore.getState(); + expect(state.pathResult).toBeNull(); + expect(state.swapQuotes).toBeNull(); + expect(state.destinationAmount).toBe("0"); + expect(state.isLoadingPath).toBe(false); + }); +}); diff --git a/__tests__/services/backend.swapQuotes.test.ts b/__tests__/services/backend.swapQuotes.test.ts new file mode 100644 index 000000000..1af55adf4 --- /dev/null +++ b/__tests__/services/backend.swapQuotes.test.ts @@ -0,0 +1,103 @@ +import { NETWORKS } from "config/constants"; +import { fetchSwapQuotes, freighterBackendV2 } from "services/backend"; + +jest.mock("services/apiFactory", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const actual = jest.requireActual("services/apiFactory"); + return { + ...actual, + createApiService: jest.fn(() => ({ + get: jest.fn(), + post: jest.fn(), + })), + }; +}); + +jest.mock("config/logger", () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + normalizeError: jest.fn((error) => error), +})); + +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const ACCOUNT = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; + +const QUOTES = { + routes: [ + { + source: "classic", + fromAmount: "100", + toAmount: "17.5450814", + paths: [ + { + share: "1", + hops: [{ venue: "stellar-dex", from: "XLM", to: USDC }], + }, + ], + fees: [], + }, + ], + unavailable: [{ source: "lifi", reason: "timeout" }], +}; + +describe("fetchSwapQuotes", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockV2Post: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockV2Post = freighterBackendV2.post as jest.MockedFunction; + mockV2Post.mockResolvedValue({ data: { data: QUOTES } }); + }); + + it("posts the quote request to the authenticated v2 client with the network query param", async () => { + await fetchSwapQuotes({ + network: NETWORKS.TESTNET, + account: ACCOUNT, + mode: "send", + fromAsset: "XLM", + toAsset: USDC, + amount: "100", + }); + + expect(mockV2Post).toHaveBeenCalledWith( + "/swap/quotes", + { + account: ACCOUNT, + mode: "send", + fromAsset: "XLM", + toAsset: USDC, + amount: "100", + }, + { params: { network: "TESTNET" } }, + ); + }); + + it("returns the unwrapped routes and unavailable sources", async () => { + const quotes = await fetchSwapQuotes({ + network: NETWORKS.PUBLIC, + account: ACCOUNT, + mode: "send", + fromAsset: "XLM", + toAsset: USDC, + amount: "100", + }); + + expect(quotes).toEqual(QUOTES); + }); + + it("rejects a body without a routes array", async () => { + mockV2Post.mockResolvedValue({ data: { data: { unavailable: [] } } }); + + await expect( + fetchSwapQuotes({ + network: NETWORKS.PUBLIC, + account: ACCOUNT, + mode: "send", + fromAsset: "XLM", + toAsset: USDC, + amount: "100", + }), + ).rejects.toThrow(/swap quotes/); + }); +}); diff --git a/src/components/screens/SwapScreen/helpers/index.ts b/src/components/screens/SwapScreen/helpers/index.ts index 7706466c6..1459b2abc 100644 --- a/src/components/screens/SwapScreen/helpers/index.ts +++ b/src/components/screens/SwapScreen/helpers/index.ts @@ -22,3 +22,7 @@ export { getQuoteExpiredOperationCodes, isQuoteExpiredResultCodes, } from "./quoteErrors"; +export { + selectSwapRoute, + swapRouteIntermediateAssets, +} from "./selectSwapRoute"; diff --git a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts new file mode 100644 index 000000000..811903e10 --- /dev/null +++ b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts @@ -0,0 +1,17 @@ +import { SwapQuotes, SwapRoute } from "config/types"; + +/** + * The route the swap uses. Classic only, not `routes[0]`, while the client + * still builds the transaction itself and can build only a classic path + * payment. + */ +export const selectSwapRoute = (quotes: SwapQuotes): SwapRoute | null => + quotes.routes.find((route) => route.source === "classic") ?? null; + +/** + * A classic route's path-payment `path`: the assets between the sold and the + * received asset, in the `XLM` / `CODE:ISSUER` form the transaction builder + * reads. Classic routes have exactly one path. + */ +export const swapRouteIntermediateAssets = (route: SwapRoute): string[] => + (route.paths[0]?.hops ?? []).slice(0, -1).map((hop) => hop.to); diff --git a/src/components/screens/SwapScreen/helpers/swapDisplayStrings.ts b/src/components/screens/SwapScreen/helpers/swapDisplayStrings.ts index e4a2dcd49..69a1f3c52 100644 --- a/src/components/screens/SwapScreen/helpers/swapDisplayStrings.ts +++ b/src/components/screens/SwapScreen/helpers/swapDisplayStrings.ts @@ -94,7 +94,7 @@ export const buildReceiveTexts = ({ receiveBigText: string; receiveSmallText: string; } => { - // `destinationAmount` arrives from Horizon/the path-finder as a raw + // `destinationAmount` arrives from the swap quote as a raw // dot-notation string ("0.1228789"). Run it through // formatTokenForDisplay so the rendered amount uses the device // locale's decimal separator (e.g. "0,1228789" on EU-style locales). diff --git a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts index 32f6030d2..01cd6e525 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts @@ -1,10 +1,9 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { Asset, Horizon } from "@stellar/stellar-sdk"; import BigNumber from "bignumber.js"; +import { selectSwapRoute } from "components/screens/SwapScreen/helpers/selectSwapRoute"; import { DestinationTokenDescriptor } from "components/screens/SwapScreen/helpers/types"; import { DEFAULT_DECIMALS, - mapNetworkToNetworkDetails, NATIVE_TOKEN_CODE, NETWORKS, } from "config/constants"; @@ -14,11 +13,13 @@ import { isNativeAssetId } from "helpers/assetIdentity"; import { calculateSpendableAmount } from "helpers/balances"; import { type HeldBalanceItem } from "hooks/useBalancesList"; import { useCallback, useRef } from "react"; +import { fetchSwapQuotes } from "services/backend"; interface UseSwapForXlmReserveParams { sourceBalance: HeldBalanceItem | undefined; bestNonXlmClassicBalance: HeldBalanceItem | undefined; network: NETWORKS; + publicKey: string | undefined; subentryCount: number; swapFee: string; setSourceToken: (id: string, symbol: string) => void; @@ -45,6 +46,7 @@ export const useSwapForXlmReserve = ({ sourceBalance, bestNonXlmClassicBalance, network, + publicKey, subentryCount, swapFee, setSourceToken, @@ -61,9 +63,9 @@ export const useSwapForXlmReserve = ({ const canOfferSwapToXlm = isCurrentSourceNonXlmClassic || !!bestNonXlmClassicBalance; - // Picks the sell token, sets XLM as the Receive token, and asks Horizon's - // strictReceivePaths how much of the sell token it would take to receive at - // least 0.5 XLM. + // Picks the sell token, sets XLM as the Receive token, and asks the backend + // for a receive quote: how much of the sell token it would take to receive + // at least 0.5 XLM. // // Sell-token resolution: // - Non-XLM classic source → reuse it. The amount survives because the @@ -89,21 +91,24 @@ export const useSwapForXlmReserve = ({ let receivedSourceAmount: string | null = null; try { - const networkDetails = mapNetworkToNetworkDetails(network); - const server = new Horizon.Server(networkDetails.networkUrl); - const sellAsset = new Asset(sellTokenCode, sellIssuer); - const result = await server - .strictReceivePaths([sellAsset], Asset.native(), "0.5") - .limit(1) - .call(); - receivedSourceAmount = result.records[0]?.source_amount ?? null; + if (publicKey) { + const quotes = await fetchSwapQuotes({ + network, + account: publicKey, + mode: "receive", + fromAsset: `${sellTokenCode}:${sellIssuer}`, + toAsset: NATIVE_TOKEN_CODE, + amount: "0.5", + }); + receivedSourceAmount = selectSwapRoute(quotes)?.fromAmount ?? null; + } } catch (error) { // No path / network error — fall back to setting source+dest without // a pre-filled amount so the user can still adjust manually. Expected // and handled, so warn (not error → no red LogBox / Sentry issue). logger.warn( "useSwapForXlmReserve.handleSwapForXlmFromSheet", - "strictReceivePaths failed", + "receive quote failed", error, ); } @@ -147,6 +152,7 @@ export const useSwapForXlmReserve = ({ sourceBalance, bestNonXlmClassicBalance, network, + publicKey, subentryCount, swapFee, setSourceToken, diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 5e70cbf38..1f187061d 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -522,6 +522,7 @@ const SwapAmountScreen: React.FC = ({ sourceBalance, bestNonXlmClassicBalance, network, + publicKey: account?.publicKey, subentryCount: account?.subentryCount ?? 0, swapFee, setSourceToken, diff --git a/src/config/types.ts b/src/config/types.ts index ca79694ac..89147b302 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -310,6 +310,49 @@ export interface TokenStatsResponse { data: TokenStats; } +/** Wire shapes of backend-v2 `POST /swap/quotes`. */ +export type SwapQuoteMode = "send" | "receive"; + +export interface SwapHop { + venue: string; + from: string; + to: string; + pool?: string; +} + +export interface SwapRoutePath { + share: string; + hops: SwapHop[]; +} + +export interface SwapFee { + name: string; + amount: string; + asset: string; +} + +export interface SwapRoute { + source: string; + fromAmount: string; + toAmount: string; + paths: SwapRoutePath[]; + fees: SwapFee[]; +} + +export interface SwapUnavailable { + source: string; + reason: string; +} + +export interface SwapQuotes { + routes: SwapRoute[]; + unavailable: SwapUnavailable[]; +} + +export interface SwapQuotesResponse { + data: SwapQuotes; +} + /** * Represents a balance with additional price and display information * diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index eaf40f4d1..84fff6610 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -1,19 +1,26 @@ -import { Horizon } from "@stellar/stellar-sdk"; import BigNumber from "bignumber.js"; -import { DestinationTokenDescriptor } from "components/screens/SwapScreen/helpers"; +import { + DestinationTokenDescriptor, + selectSwapRoute, + swapRouteIntermediateAssets, +} from "components/screens/SwapScreen/helpers"; import { DEFAULT_DECIMALS, + NATIVE_TOKEN_CODE, NETWORKS, - mapNetworkToNetworkDetails, } from "config/constants"; import { logger } from "config/logger"; -import { PricedBalance, TokenTypeWithCustomToken } from "config/types"; +import { + PricedBalance, + SwapQuotes, + TokenTypeWithCustomToken, +} from "config/types"; import { useDebugStore } from "ducks/debug"; -import { isNativeAssetId } from "helpers/assetIdentity"; import { formatBigNumberForDisplay } from "helpers/formatAmount"; import { isContractId } from "helpers/soroban"; import { type HeldBalanceItem } from "hooks/useBalancesList"; import { t } from "i18next"; +import { fetchSwapQuotes } from "services/backend"; import { getTokenForPayment } from "services/transactionService"; import { create } from "zustand"; @@ -25,19 +32,6 @@ export interface SwapPathResult { conversionRate: string; } -interface HorizonPathToken { - asset_type: string; - asset_code?: string; - asset_issuer?: string; -} - -interface SwapPathData { - sourceAmount: string; - destinationAmount: string; - path: string[]; - conversionRate: string; -} - interface SwapState { sourceTokenId: string; sourceTokenSymbol: string; @@ -52,6 +46,8 @@ interface SwapState { sourceAmountDisplay: string; // Display value (locale formatted) destinationAmount: string; pathResult: SwapPathResult | null; + /** Every route and unavailable source from the last quote, for the route list. */ + swapQuotes: SwapQuotes | null; isLoadingPath: boolean; pathError: string | null; isBuilding: boolean; @@ -83,6 +79,7 @@ const initialState = { sourceAmountDisplay: "0", destinationAmount: "0", pathResult: null, + swapQuotes: null as SwapQuotes | null, isLoadingPath: false, pathError: null, isBuilding: false, @@ -97,59 +94,26 @@ const computeDestMinWithSlippage = ( return new BigNumber(destinationAmount).times(new BigNumber(mult)).toFixed(7); }; -/** - * Finds the best swap path using Horizon's strict send paths endpoint - * This is for classic token swaps using Stellar's built-in DEX - */ -const findClassicSwapPath = async (params: { - sourceBalance: PricedBalance; - destinationBalance: PricedBalance; - sourceAmount: string; - network: NETWORKS; -}): Promise => { - const { sourceBalance, destinationBalance, sourceAmount, network } = params; - - try { - const networkDetails = mapNetworkToNetworkDetails(network); - const server = new Horizon.Server(networkDetails.networkUrl); - - const sourceToken = getTokenForPayment(sourceBalance); - const destToken = getTokenForPayment(destinationBalance); - - const pathsResult = await server - .strictSendPaths(sourceToken, sourceAmount, [destToken]) - .limit(1) - .call(); - - if (pathsResult.records.length === 0) { - return null; - } - - const bestPath = pathsResult.records[0]; - - const path: string[] = bestPath.path.map((token: HorizonPathToken) => { - if (isNativeAssetId(token.asset_type)) { - return "native"; - } - return `${token.asset_code}:${token.asset_issuer}`; - }); - - const sourceAmountBN = new BigNumber(sourceAmount); - const destAmountBN = new BigNumber(bestPath.destination_amount); - const conversionRate = destAmountBN.dividedBy(sourceAmountBN).toFixed(7); +const toSwapWireAsset = (balance: PricedBalance): string => { + const token = getTokenForPayment(balance); + return token.isNative() + ? NATIVE_TOKEN_CODE + : `${token.getCode()}:${token.getIssuer()}`; +}; - return { - sourceAmount, - destinationAmount: bestPath.destination_amount, - path, - conversionRate, - }; - } catch (error) { - logger.error("SwapStore", "Failed to find classic swap path", error); - throw error; +const quoteErrorMessage = (error: unknown): string | null => { + const status = (error as { status?: unknown } | null)?.status; + if (status === 400) return t("swapScreen.errors.noPathFound"); + if (status === 502 || status === 503) { + return t("swapScreen.errors.swapsUnavailable"); } + return null; }; +// Quotes can take seconds, so a slower earlier request must not overwrite a +// newer one or a cleared path. +let quoteRequestId = 0; + export const useSwapStore = create((set) => ({ ...initialState, @@ -200,9 +164,19 @@ export const useSwapStore = create((set) => ({ sourceAmount, slippage, network, + publicKey, } = params; - set({ isLoadingPath: true, pathError: null, pathResult: null }); + quoteRequestId += 1; + const requestId = quoteRequestId; + const isStale = () => requestId !== quoteRequestId; + + set({ + isLoadingPath: true, + pathError: null, + pathResult: null, + swapQuotes: null, + }); try { const { forceSwapPathFailure } = useDebugStore.getState(); @@ -228,46 +202,58 @@ export const useSwapStore = create((set) => ({ return; } - // TODO: Add Soroswap support for Testnet - const pathResult = await findClassicSwapPath({ - sourceBalance, - destinationBalance, - sourceAmount, + const swapQuotes = await fetchSwapQuotes({ network, + account: publicKey, + mode: "send", + fromAsset: toSwapWireAsset(sourceBalance), + toAsset: toSwapWireAsset(destinationBalance), + amount: sourceAmount, }); - if (!pathResult) { + if (isStale()) return; + + const route = selectSwapRoute(swapQuotes); + + if (!route) { set({ isLoadingPath: false, + swapQuotes, pathError: t("swapScreen.errors.noPathFound"), }); return; } - const destinationAmountMin = computeDestMinWithSlippage( - pathResult.destinationAmount, - slippage, - ); - - const finalPathResult: SwapPathResult = { - ...pathResult, - destinationAmountMin, + const pathResult: SwapPathResult = { + sourceAmount, + destinationAmount: route.toAmount, + destinationAmountMin: computeDestMinWithSlippage( + route.toAmount, + slippage, + ), + path: swapRouteIntermediateAssets(route), + conversionRate: new BigNumber(route.toAmount) + .dividedBy(new BigNumber(sourceAmount)) + .toFixed(7), }; set({ isLoadingPath: false, - pathResult: finalPathResult, - destinationAmount: pathResult.destinationAmount, + swapQuotes, + pathResult, + destinationAmount: route.toAmount, }); } catch (error) { + if (isStale()) return; + const errorMessage = - error instanceof Error ? error.message : String(error); + (error as { message?: string } | null)?.message ?? String(error); logger.error("SwapStore", "Failed to find swap path", error); - const displayError = __DEV__ - ? errorMessage - : t("swapScreen.errors.pathFindFailed"); + const displayError = + quoteErrorMessage(error) ?? + (__DEV__ ? errorMessage : t("swapScreen.errors.pathFindFailed")); set({ isLoadingPath: false, @@ -276,14 +262,21 @@ export const useSwapStore = create((set) => ({ } }, - clearPath: () => + clearPath: () => { + quoteRequestId += 1; set({ pathResult: null, + swapQuotes: null, destinationAmount: "0", pathError: null, - }), + isLoadingPath: false, + }); + }, - resetSwap: () => set(initialState), + resetSwap: () => { + quoteRequestId += 1; + set(initialState); + }, })); /** diff --git a/src/hooks/useTokenFiatConverter/reducer.ts b/src/hooks/useTokenFiatConverter/reducer.ts index 165cefaea..1e4b8dadf 100644 --- a/src/hooks/useTokenFiatConverter/reducer.ts +++ b/src/hooks/useTokenFiatConverter/reducer.ts @@ -833,7 +833,7 @@ export const createTokenFiatConverterReducer = // Guard non-finite values (e.g. user pasted "1e999" → Infinity, or // a malformed input that returns NaN) so the store never holds a // string that would crash BigNumber arithmetic downstream or get - // serialized into a Horizon strictSendPaths request. + // serialized into a swap quote request. const bnInternal = new BigNumber(rawInternal); // Reject amounts above the protocol-level cap when a maxTokenAmount diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index ec9d5763d..949ddd884 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -718,6 +718,7 @@ "insufficientXlmForFees": "Insufficient XLM for transaction fees. You need at least {{fee}} XLM to pay the transaction fee.", "noPathFound": "No path found between these tokens", "pathFindFailed": "Couldn't fetch swap path. Please try again.", + "swapsUnavailable": "Swaps are temporarily unavailable", "customTokenSwapNotSupported": "Swapping custom tokens is not supported yet", "quoteExpired": "Quote has expired, please try again to get a new quote" }, diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index ba2c658a4..88c78fde1 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -684,6 +684,7 @@ "insufficientXlmForFees": "XLM insuficiente para taxas de transação. Você precisa de pelo menos {{fee}} XLM para pagar a taxa de transação.", "noPathFound": "Nenhum caminho encontrado entre estes tokens", "pathFindFailed": "Não foi possível buscar o caminho de troca. Tente novamente.", + "swapsUnavailable": "As trocas estão temporariamente indisponíveis", "customTokenSwapNotSupported": "Troca de tokens customizados ainda não é suportada", "quoteExpired": "A cotação expirou, tente novamente para obter uma nova cotação" }, diff --git a/src/services/backend.ts b/src/services/backend.ts index 6a6d2db55..2c342f9d3 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -32,6 +32,9 @@ import { GetTokenDetailsParams, DiscoverProtocol, PriceHistoryRange, + SwapQuoteMode, + SwapQuotes, + SwapQuotesResponse, TokenDetailsResponse, TokenIdentifier, TokenPriceHistory, @@ -1007,6 +1010,41 @@ export const fetchTokenStats = async ({ return data.data; }; +export interface FetchSwapQuotesParams { + network: NETWORKS; + /** The account swapping (the JWT identifies the wallet, not the account) */ + account: string; + mode: SwapQuoteMode; + /** `XLM`, `CODE:ISSUER` or a `C…` contract ID */ + fromAsset: string; + /** `XLM`, `CODE:ISSUER` or a `C…` contract ID */ + toAsset: string; + /** Decimal string: sold for `send`, received for `receive` */ + amount: string; +} + +/** + * Quotes a swap from every backend source, best route first. A 200 with empty + * `routes` means no source can quote the pair; HTTP failures reject with the + * normalized `ApiError` so callers can branch on `status`. + */ +export const fetchSwapQuotes = async ({ + network, + ...body +}: FetchSwapQuotesParams): Promise => { + const { data } = await freighterBackendV2.post( + "/swap/quotes", + body, + { params: { network } }, + ); + + if (!Array.isArray(data?.data?.routes)) { + throw new Error("Unexpected swap quotes response shape"); + } + + return data.data; +}; + /** * Fetches detailed information about a specific token contract * @async From 4dfb7360b7682d8d39f58933cc8510df90e631b4 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 4 Nov 2026 21:35:22 -0500 Subject: [PATCH 02/15] fix(swap): build only from a quote for the current inputs; show outages as unavailable Stale quote (pre-pr-review D1): pathResult now carries quoteKey, the account, network, mode, assets and amount of the request it answered. SwapAmountScreen computes swapQuoteKey for the current inputs. The CTA stays in its loading state while they differ (amount edited inside the 500 ms debounce, destination/account/network changed), and setupSwapTransaction refuses to build from a mismatched quote with the existing "quote expired" error. Outage vs no route (D2, D3; ruling from spec section 8): transport failure (status 0), 401 and any 5xx show "Swaps are temporarily unavailable"; so does a 200 without a classic route when classic is listed unavailable with error, timeout or rate_limited, which is now logged to Sentry. no_route, insufficient_balance, no classic entry and 400 keep "No path found". The reserve sheet uses the same classifier and warns on a classic outage, as it already did for failed quotes. Adds the missing tests for resetSwap and the error-path stale check. Co-Authored-By: Claude Opus 5.5 --- .../SwapScreen/SwapAmountScreen.test.tsx | 15 ++- .../SwapScreen/hooks/useSwapCtaState.test.ts | 47 +++++++ .../hooks/useSwapForXlmReserve.test.ts | 30 +++++ .../SwapScreen/useSwapTransaction.test.ts | 18 +++ __tests__/ducks/swap.findSwapPath.test.ts | 126 ++++++++++++++++-- .../screens/SwapScreen/helpers/index.ts | 2 + .../SwapScreen/helpers/selectSwapRoute.ts | 28 ++++ .../SwapScreen/hooks/useSwapCtaState.ts | 11 +- .../SwapScreen/hooks/useSwapForXlmReserve.ts | 15 ++- .../SwapScreen/hooks/useSwapTransaction.ts | 9 ++ .../SwapScreen/screens/SwapAmountScreen.tsx | 29 +++- src/ducks/swap.ts | 84 ++++++++---- 12 files changed, 367 insertions(+), 47 deletions(-) create mode 100644 __tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index 8304b2308..47d2d6d15 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -106,7 +106,7 @@ type SwapStoreState = { sourceAmount: string; sourceAmountDisplay: string; destinationAmount: string; - pathResult: null | { destinationAmount: string }; + pathResult: null | { destinationAmount: string; quoteKey: string }; isLoadingPath: boolean; pathError: string | null; setSourceToken: jest.Mock; @@ -145,6 +145,7 @@ const makeDefaultSwapState = (): SwapStoreState => ({ jest.mock("ducks/swap", () => ({ useSwapStore: jest.fn(), + swapQuoteKey: jest.fn(() => "quote-key"), // Pass-through adapter — tests can inspect the call by passing in a // descriptor that doesn't match any held balance and asserting that the // useSwapPathFinding mock receives an object with `tokenCode`/`id` from @@ -834,7 +835,7 @@ describe("SwapAmountScreen", () => { it("shows 'Review swap' (enabled) when path-finding succeeds", async () => { setSwapStoreState({ sourceAmount: "1", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, }); const { getByTestId } = renderWithProviders( @@ -876,7 +877,7 @@ describe("SwapAmountScreen", () => { setSwapStoreState({ sourceAmount: "1", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, }); const { getByTestId } = renderWithProviders( @@ -969,7 +970,7 @@ describe("SwapAmountScreen", () => { setSwapStoreState({ sourceAmount: "1", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, destinationToken: { id: "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", tokenCode: "FTT", @@ -1011,7 +1012,7 @@ describe("SwapAmountScreen", () => { // The pre-flight gate should NOT fire, so setupSwapTransaction is called. setSwapStoreState({ sourceAmount: "1", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, destinationToken: { id: "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", tokenCode: "FTT", @@ -1077,7 +1078,7 @@ describe("SwapAmountScreen", () => { // sourceTokenId = "XLM" (NATIVE_TOKEN_CODE) — XLM is the source sourceTokenId: "XLM", sourceAmount: "0.39", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, destinationToken: { id: "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", tokenCode: "FTT", @@ -1139,7 +1140,7 @@ describe("SwapAmountScreen", () => { setSwapStoreState({ sourceTokenId: "XLM", sourceAmount: "0.89", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, destinationToken: { id: "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", tokenCode: "FTT", diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts new file mode 100644 index 000000000..81c99f513 --- /dev/null +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts @@ -0,0 +1,47 @@ +import { renderHook } from "@testing-library/react-hooks"; +import BigNumber from "bignumber.js"; +import { useSwapCtaState } from "components/screens/SwapScreen/hooks/useSwapCtaState"; +import { TokenTypeWithCustomToken } from "config/types"; + +jest.mock("hooks/useAppTranslation", () => () => ({ t: (k: string) => k })); + +const props = (quoteKey: string, currentQuoteKey: string | null) => ({ + sourceBalance: { tokenCode: "XLM" } as never, + destinationTokenDescriptor: { + id: "USDC:GA5Z", + tokenCode: "USDC", + issuer: "GA5Z", + decimals: 7, + tokenType: TokenTypeWithCustomToken.CREDIT_ALPHANUM4, + requiresTrustline: false, + }, + sourceAmount: "10", + spendableAmount: new BigNumber("100"), + isLoadingPath: false, + isBuilding: false, + pathResult: { + sourceAmount: "10", + destinationAmount: "2", + destinationAmountMin: "1.98", + path: [], + conversionRate: "0.2", + quoteKey, + }, + pathError: null, + amountError: null, + currentQuoteKey, +}); + +describe("useSwapCtaState — quote freshness", () => { + it("offers review when the quote answers the current inputs", () => { + const { result } = renderHook(() => useSwapCtaState(props("k1", "k1"))); + + expect(result.current.ctaState.kind).toBe("review"); + }); + + it("holds review (loading) while the quote answers older inputs", () => { + const { result } = renderHook(() => useSwapCtaState(props("k1", "k2"))); + + expect(result.current.ctaState.kind).toBe("loading"); + }); +}); diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts index 4dd0bc2ff..64b5b690b 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react-hooks"; import BigNumber from "bignumber.js"; import { useSwapForXlmReserve } from "components/screens/SwapScreen/hooks/useSwapForXlmReserve"; import { NETWORKS } from "config/constants"; +import { logger } from "config/logger"; import { TokenTypeWithCustomToken } from "config/types"; import { type HeldBalanceItem } from "hooks/useBalancesList"; import { fetchSwapQuotes } from "services/backend"; @@ -145,4 +146,33 @@ describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { expect.objectContaining({ id: "XLM" }), ); }); + + it("reports a classic outage the way a failed quote is reported", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [], + unavailable: [{ source: "classic", reason: "timeout" }], + }); + const { result, setTokenAmount } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(setTokenAmount).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("does not report a plain no-route answer", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [], + unavailable: [{ source: "classic", reason: "no_route" }], + }); + const { result } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts index 35e160d65..344a1a20a 100644 --- a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts +++ b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts @@ -131,7 +131,9 @@ const baseParams: Parameters[0] = { path: [], destinationAmount: "2.5", destinationAmountMin: "2.4", + quoteKey: "quote-key", } as never, + currentQuoteKey: "quote-key", account: { publicKey: "GA...", privateKey: "SA...", @@ -340,6 +342,22 @@ describe("useSwapTransaction", () => { }); }); + describe("setupSwapTransaction — quote freshness", () => { + it("refuses to build from a quote for other inputs", async () => { + const { result } = renderHook(() => + useSwapTransaction({ ...baseParams, currentQuoteKey: "newer-key" }), + ); + + await act(async () => { + await expect(result.current.setupSwapTransaction()).rejects.toThrow( + "swapScreen.errors.quoteExpired", + ); + }); + + expect(mockBuildSwapTransaction).not.toHaveBeenCalled(); + }); + }); + describe("setupSwapTransaction — includeTrustline wiring", () => { it("passes includeTrustline when destinationToken.requiresTrustline is true", async () => { act(() => { diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index f06e0d02e..5c65b4e59 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -1,7 +1,8 @@ import BigNumber from "bignumber.js"; import { NETWORKS } from "config/constants"; +import { logger } from "config/logger"; import { PricedBalance, SwapQuotes, SwapRoute } from "config/types"; -import { useSwapStore } from "ducks/swap"; +import { swapQuoteKey, useSwapStore } from "ducks/swap"; import { fetchSwapQuotes } from "services/backend"; jest.mock("services/backend", () => ({ @@ -74,15 +75,18 @@ const lifiRoute: SwapRoute = { fees: [], }; +const inputs = (sourceAmount = "10") => ({ + sourceBalance: xlmBalance, + destinationBalance: usdcBalance, + sourceAmount, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, +}); + const find = (sourceAmount = "10") => - useSwapStore.getState().findSwapPath({ - sourceBalance: xlmBalance, - destinationBalance: usdcBalance, - sourceAmount, - slippage: 1, - network: NETWORKS.PUBLIC, - publicKey: ACCOUNT, - }); + useSwapStore + .getState() + .findSwapPath({ ...inputs(sourceAmount), slippage: 1 }); const apiError = (status: number) => ({ message: `Request failed with status code ${status}`, @@ -132,6 +136,7 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { destinationAmountMin: "1.9800000", path: [AQUA], conversionRate: "0.2000000", + quoteKey: swapQuoteKey(inputs("10")), }); expect(state.destinationAmount).toBe("2.0000000"); expect(state.swapQuotes).toEqual(quotes); @@ -175,7 +180,7 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { ); }); - it.each([502, 503])( + it.each([0, 401, 500, 502, 503, 504])( "shows swaps temporarily unavailable on %i", async (status) => { mockFetchSwapQuotes.mockRejectedValue(apiError(status)); @@ -232,4 +237,105 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { expect(state.destinationAmount).toBe("0"); expect(state.isLoadingPath).toBe(false); }); + + it("reports a thrown outage to Sentry", async () => { + mockFetchSwapQuotes.mockRejectedValue(apiError(0)); + + await find(); + + expect(logger.error).toHaveBeenCalled(); + }); + + it.each(["error", "timeout", "rate_limited"])( + "shows swaps temporarily unavailable and reports when classic is unavailable with %s", + async (reason) => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [lifiRoute], + unavailable: [{ source: "classic", reason }], + }); + + await find(); + + const state = useSwapStore.getState(); + expect(state.pathResult).toBeNull(); + expect(state.pathError).toBe("swapScreen.errors.swapsUnavailable"); + expect(logger.error).toHaveBeenCalled(); + }, + ); + + it.each(["no_route", "insufficient_balance"])( + "shows no path when classic is unavailable with %s", + async (reason) => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [], + unavailable: [{ source: "classic", reason }], + }); + + await find(); + + expect(useSwapStore.getState().pathError).toBe( + "swapScreen.errors.noPathFound", + ); + expect(logger.error).not.toHaveBeenCalled(); + }, + ); + + it("ignores an in-flight response after resetSwap", async () => { + let resolveFirst: (q: SwapQuotes) => void = () => {}; + mockFetchSwapQuotes.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + const first = find("10"); + useSwapStore.getState().resetSwap(); + resolveFirst({ routes: [classicRoute], unavailable: [] }); + await first; + + const state = useSwapStore.getState(); + expect(state.pathResult).toBeNull(); + expect(state.swapQuotes).toBeNull(); + expect(state.isLoadingPath).toBe(false); + }); + + it("ignores a failure that lands after a newer request was started", async () => { + let rejectFirst: (e: unknown) => void = () => {}; + mockFetchSwapQuotes + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectFirst = reject; + }), + ) + .mockResolvedValueOnce({ routes: [classicRoute], unavailable: [] }); + + const first = find("10"); + await find("20"); + rejectFirst(apiError(503)); + await first; + + const state = useSwapStore.getState(); + expect(state.pathError).toBeNull(); + expect(state.pathResult).not.toBeNull(); + }); +}); + +describe("swapQuoteKey", () => { + it("changes with the amount, the destination, the account and the network", () => { + const base = swapQuoteKey(inputs("10")); + + expect(swapQuoteKey(inputs("10"))).toBe(base); + expect(swapQuoteKey(inputs("11"))).not.toBe(base); + expect( + swapQuoteKey({ ...inputs("10"), destinationBalance: xlmBalance }), + ).not.toBe(base); + expect(swapQuoteKey({ ...inputs("10"), publicKey: "GOTHER" })).not.toBe( + base, + ); + expect( + swapQuoteKey({ ...inputs("10"), network: NETWORKS.TESTNET }), + ).not.toBe(base); + }); }); diff --git a/src/components/screens/SwapScreen/helpers/index.ts b/src/components/screens/SwapScreen/helpers/index.ts index 1459b2abc..5276fb0c2 100644 --- a/src/components/screens/SwapScreen/helpers/index.ts +++ b/src/components/screens/SwapScreen/helpers/index.ts @@ -23,6 +23,8 @@ export { isQuoteExpiredResultCodes, } from "./quoteErrors"; export { + missingSwapRouteReason, selectSwapRoute, + swapQuoteErrorReason, swapRouteIntermediateAssets, } from "./selectSwapRoute"; diff --git a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts index 811903e10..a29e143a8 100644 --- a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts +++ b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts @@ -1,4 +1,5 @@ import { SwapQuotes, SwapRoute } from "config/types"; +import { isApiError } from "services/apiFactory"; /** * The route the swap uses. Classic only, not `routes[0]`, while the client @@ -15,3 +16,30 @@ export const selectSwapRoute = (quotes: SwapQuotes): SwapRoute | null => */ export const swapRouteIntermediateAssets = (route: SwapRoute): string[] => (route.paths[0]?.hops ?? []).slice(0, -1).map((hop) => hop.to); + +const OUTAGE_REASONS = ["error", "timeout", "rate_limited"]; + +/** + * Why `selectSwapRoute` found nothing: an outage of the source it selects, or + * a pair that source can't quote. Change with `selectSwapRoute`. + */ +export const missingSwapRouteReason = ( + quotes: SwapQuotes, +): "unavailable" | "noRoute" => { + const classic = quotes.unavailable.find((u) => u.source === "classic"); + return classic && OUTAGE_REASONS.includes(classic.reason) + ? "unavailable" + : "noRoute"; +}; + +/** Maps a failed quote request; null leaves it to the caller's generic handling. */ +export const swapQuoteErrorReason = ( + error: unknown, +): "unavailable" | "noRoute" | null => { + if (!isApiError(error)) return null; + if (error.status === 400) return "noRoute"; + if (error.status === 0 || error.status === 401 || error.status >= 500) { + return "unavailable"; + } + return null; +}; diff --git a/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts b/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts index 0d510cb83..cb4bc41fc 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts @@ -42,6 +42,7 @@ export const useSwapCtaState = ({ pathResult, pathError, amountError, + currentQuoteKey, }: { sourceBalance: PricedBalance | undefined; destinationTokenDescriptor: DestinationTokenDescriptor | null; @@ -52,6 +53,8 @@ export const useSwapCtaState = ({ pathResult: SwapPathResult | null; pathError: string | null; amountError: string | null; + /** `swapQuoteKey` of the current inputs. */ + currentQuoteKey: string | null; }): { ctaState: SwapCtaState; ctaLabel: string; isCtaDisabled: boolean } => { const { t } = useAppTranslation(); @@ -74,7 +77,12 @@ export const useSwapCtaState = ({ if (isLoadingPath || isBuilding) return { kind: "loading" }; - if (pathResult && !pathError) return { kind: "review" }; + if (pathResult && !pathError) { + // An older quote stays in the store until the debounced re-quote lands. + return pathResult.quoteKey === currentQuoteKey + ? { kind: "review" } + : { kind: "loading" }; + } // Path-finding finished without a result (or threw) — keep the user on // the amount step. The persistent toast (set up by the screen) already @@ -89,6 +97,7 @@ export const useSwapCtaState = ({ isBuilding, pathResult, pathError, + currentQuoteKey, ]); const ctaLabel = useMemo(() => { diff --git a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts index 01cd6e525..6b4c50be0 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts @@ -1,6 +1,9 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import BigNumber from "bignumber.js"; -import { selectSwapRoute } from "components/screens/SwapScreen/helpers/selectSwapRoute"; +import { + missingSwapRouteReason, + selectSwapRoute, +} from "components/screens/SwapScreen/helpers/selectSwapRoute"; import { DestinationTokenDescriptor } from "components/screens/SwapScreen/helpers/types"; import { DEFAULT_DECIMALS, @@ -101,6 +104,16 @@ export const useSwapForXlmReserve = ({ amount: "0.5", }); receivedSourceAmount = selectSwapRoute(quotes)?.fromAmount ?? null; + if ( + !receivedSourceAmount && + missingSwapRouteReason(quotes) === "unavailable" + ) { + logger.warn( + "useSwapForXlmReserve.handleSwapForXlmFromSheet", + "receive quote source unavailable", + quotes.unavailable, + ); + } } } catch (error) { // No path / network error — fall back to setting source+dest without diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index 8751fc21e..f553aece8 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -75,6 +75,8 @@ interface SwapTransactionParams { sourceBalance: PricedBalance | undefined; destinationTokenInput: PricedBalance | undefined; pathResult: SwapPathResult | null; + /** `swapQuoteKey` of the current inputs; `pathResult` must match it. */ + currentQuoteKey: string | null; account: ActiveAccount | null; network: NETWORKS; navigation: NativeStackNavigationProp< @@ -107,6 +109,7 @@ export const useSwapTransaction = ({ sourceBalance, destinationTokenInput, pathResult, + currentQuoteKey, account, network, navigation, @@ -141,6 +144,10 @@ export const useSwapTransaction = ({ return undefined; } + if (pathResult.quoteKey !== currentQuoteKey) { + throw new Error(t("swapScreen.errors.quoteExpired")); + } + // Get fresh settings values each time the function is called const { swapFee: freshSwapFee, swapTimeout: freshSwapTimeout } = useSwapSettingsStore.getState(); @@ -198,11 +205,13 @@ export const useSwapTransaction = ({ sourceBalance, destinationTokenInput, pathResult, + currentQuoteKey, buildSwapTransaction, account?.publicKey, sourceAmount, network, scanTransaction, + t, ]); const executeSwap = useCallback(async () => { diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 1f187061d..f1ac4ec45 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -57,7 +57,11 @@ import { SWAP_ROUTES, SwapStackParamList } from "config/routes"; import { FormattedSearchTokenRecord } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { useDebugStore } from "ducks/debug"; -import { descriptorAsPathBalance, useSwapStore } from "ducks/swap"; +import { + descriptorAsPathBalance, + swapQuoteKey, + useSwapStore, +} from "ducks/swap"; import { useSwapSettingsStore } from "ducks/swapSettings"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { isNativeAssetId } from "helpers/assetIdentity"; @@ -250,6 +254,26 @@ const SwapAmountScreen: React.FC = ({ return undefined; }, [destinationBalance, destinationTokenDescriptor]); + const currentQuoteKey = useMemo( + () => + sourceBalance && destinationForPath && account?.publicKey + ? swapQuoteKey({ + sourceBalance, + destinationBalance: destinationForPath, + sourceAmount, + network, + publicKey: account.publicKey, + }) + : null, + [ + sourceBalance, + destinationForPath, + sourceAmount, + network, + account?.publicKey, + ], + ); + useSwapPathFinding({ sourceBalance, destinationTokenForPath: destinationForPath, @@ -273,7 +297,7 @@ const SwapAmountScreen: React.FC = ({ sourceBalance, destinationTokenInput: destinationForPath, pathResult, - + currentQuoteKey, account, network, navigation, @@ -384,6 +408,7 @@ const SwapAmountScreen: React.FC = ({ pathResult, pathError, amountError, + currentQuoteKey, }); useEffect(() => { diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index 84fff6610..cc779970b 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -1,7 +1,9 @@ import BigNumber from "bignumber.js"; import { DestinationTokenDescriptor, + missingSwapRouteReason, selectSwapRoute, + swapQuoteErrorReason, swapRouteIntermediateAssets, } from "components/screens/SwapScreen/helpers"; import { @@ -30,6 +32,8 @@ export interface SwapPathResult { destinationAmountMin: string; path: string[]; conversionRate: string; + /** `swapQuoteKey` of the inputs this quote answered. */ + quoteKey: string; } interface SwapState { @@ -101,15 +105,44 @@ const toSwapWireAsset = (balance: PricedBalance): string => { : `${token.getCode()}:${token.getIssuer()}`; }; -const quoteErrorMessage = (error: unknown): string | null => { - const status = (error as { status?: unknown } | null)?.status; - if (status === 400) return t("swapScreen.errors.noPathFound"); - if (status === 502 || status === 503) { - return t("swapScreen.errors.swapsUnavailable"); +interface SwapQuoteInputs { + sourceBalance: PricedBalance; + destinationBalance: PricedBalance; + sourceAmount: string; + network: NETWORKS; + publicKey: string; +} + +const sendQuoteRequest = (inputs: SwapQuoteInputs) => ({ + network: inputs.network, + account: inputs.publicKey, + mode: "send" as const, + fromAsset: toSwapWireAsset(inputs.sourceBalance), + toAsset: toSwapWireAsset(inputs.destinationBalance), + amount: inputs.sourceAmount, +}); + +const quoteKeyOf = (r: ReturnType): string => + [r.account, r.network, r.mode, r.fromAsset, r.toAsset, r.amount].join("|"); + +/** + * Identifies the send-quote request for these inputs. Review and build use + * `pathResult` only when its `quoteKey` equals the key of the current inputs. + * Null when the inputs can't be quoted. + */ +export const swapQuoteKey = (inputs: SwapQuoteInputs): string | null => { + try { + return quoteKeyOf(sendQuoteRequest(inputs)); + } catch { + return null; } - return null; }; +const QUOTE_ERROR_KEYS = { + unavailable: "swapScreen.errors.swapsUnavailable", + noRoute: "swapScreen.errors.noPathFound", +} as const; + // Quotes can take seconds, so a slower earlier request must not overwrite a // newer one or a cleared path. let quoteRequestId = 0; @@ -158,14 +191,8 @@ export const useSwapStore = create((set) => ({ }, findSwapPath: async (params) => { - const { - sourceBalance, - destinationBalance, - sourceAmount, - slippage, - network, - publicKey, - } = params; + const { sourceBalance, destinationBalance, sourceAmount, slippage } = + params; quoteRequestId += 1; const requestId = quoteRequestId; @@ -202,24 +229,26 @@ export const useSwapStore = create((set) => ({ return; } - const swapQuotes = await fetchSwapQuotes({ - network, - account: publicKey, - mode: "send", - fromAsset: toSwapWireAsset(sourceBalance), - toAsset: toSwapWireAsset(destinationBalance), - amount: sourceAmount, - }); + const request = sendQuoteRequest(params); + const swapQuotes = await fetchSwapQuotes(request); if (isStale()) return; const route = selectSwapRoute(swapQuotes); if (!route) { + const reason = missingSwapRouteReason(swapQuotes); + if (reason === "unavailable") { + logger.error( + "SwapStore", + "Swap source unavailable", + swapQuotes.unavailable, + ); + } set({ isLoadingPath: false, swapQuotes, - pathError: t("swapScreen.errors.noPathFound"), + pathError: t(QUOTE_ERROR_KEYS[reason]), }); return; } @@ -235,6 +264,7 @@ export const useSwapStore = create((set) => ({ conversionRate: new BigNumber(route.toAmount) .dividedBy(new BigNumber(sourceAmount)) .toFixed(7), + quoteKey: quoteKeyOf(request), }; set({ @@ -251,9 +281,11 @@ export const useSwapStore = create((set) => ({ logger.error("SwapStore", "Failed to find swap path", error); - const displayError = - quoteErrorMessage(error) ?? - (__DEV__ ? errorMessage : t("swapScreen.errors.pathFindFailed")); + const reason = swapQuoteErrorReason(error); + const fallbackError = __DEV__ + ? errorMessage + : t("swapScreen.errors.pathFindFailed"); + const displayError = reason ? t(QUOTE_ERROR_KEYS[reason]) : fallbackError; set({ isLoadingPath: false, From 6d8025c041fcd69d4b42bdc8c6b0165aab777dd5 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 4 Nov 2026 21:48:39 -0500 Subject: [PATCH 03/15] fix(swap): plain-decimal quote amounts, 429 as unavailable, build-time slippage - B1: the quote request and quote key send the amount as a plain decimal (BigNumber.toFixed()), so "5." and "1e-7" no longer 400 against the backend's ^[0-9]+(\.[0-9]+)?$ check. - B2: an HTTP 429 from the backend (Traefik rate limit) shows "Swaps are temporarily unavailable", like a source-level rate_limited. - M1: setupSwapTransaction computes the minimum received from the quote's destinationAmount and the current slippage setting, since slippage is not in the quote key and can change in the settings sheet after quoting. - M4: the reserve sheet no longer requests a receive quote when the sell side falls back to another token and the result would be discarded. - M7: a settings change while the matching quote is still pending skips the rebuild instead of throwing "quote expired" (Review is disabled until that quote lands); the throw stays as the build-to-sign backstop. Co-Authored-By: Claude Opus 5.5 --- .../SwapScreen/SwapAmountScreen.test.tsx | 50 +++++++++++++++++++ .../helpers/selectSwapRoute.test.ts | 13 +++++ .../hooks/useSwapForXlmReserve.test.ts | 24 +++++++++ .../SwapScreen/useSwapTransaction.test.ts | 15 ++++++ __tests__/ducks/swap.findSwapPath.test.ts | 23 ++++++++- .../SwapScreen/helpers/selectSwapRoute.ts | 2 +- .../SwapScreen/hooks/useSwapForXlmReserve.ts | 2 +- .../SwapScreen/hooks/useSwapTransaction.ts | 20 ++++++-- .../SwapScreen/screens/SwapAmountScreen.tsx | 5 +- src/ducks/swap.ts | 5 +- 10 files changed, 149 insertions(+), 10 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index 47d2d6d15..7a9f2b7fe 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -3,6 +3,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { fireEvent } from "@testing-library/react-native"; import BigNumber from "bignumber.js"; +import BottomSheet from "components/BottomSheet"; import SwapAmountScreen from "components/screens/SwapScreen/screens/SwapAmountScreen"; import Icon from "components/sds/Icon"; import { AnalyticsEvent } from "config/analyticsConfig"; @@ -1892,4 +1893,53 @@ describe("SwapAmountScreen", () => { ); }); }); + + describe("settings change rebuild", () => { + const fireSettingsChange = ( + getAllByType: (type: unknown) => { props: Record }[], + ) => { + const settingsSheet = getAllByType(BottomSheet).find( + (sheet) => + (sheet.props.customContent as { props?: Record }) + ?.props?.onSettingsChange, + ); + const { onSettingsChange } = ( + settingsSheet?.props.customContent as { + props: { onSettingsChange: () => void }; + } + ).props; + return act(async () => { + onSettingsChange(); + await Promise.resolve(); + }); + }; + + it("rebuilds when the quote matches the current inputs", async () => { + setSwapStoreState({ + sourceAmount: "1", + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, + }); + const { UNSAFE_getAllByType } = renderWithProviders( + , + ); + + await fireSettingsChange(UNSAFE_getAllByType as never); + + expect(mockSetupSwapTransaction).toHaveBeenCalled(); + }); + + it("skips the rebuild while the matching quote is still pending", async () => { + setSwapStoreState({ + sourceAmount: "1", + pathResult: { destinationAmount: "2", quoteKey: "older-key" }, + }); + const { UNSAFE_getAllByType } = renderWithProviders( + , + ); + + await fireSettingsChange(UNSAFE_getAllByType as never); + + expect(mockSetupSwapTransaction).not.toHaveBeenCalled(); + }); + }); }); diff --git a/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts index ef3284638..9e8ead9ea 100644 --- a/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts +++ b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts @@ -1,5 +1,6 @@ import { selectSwapRoute, + swapQuoteErrorReason, swapRouteIntermediateAssets, } from "components/screens/SwapScreen/helpers/selectSwapRoute"; import { SwapQuotes, SwapRoute } from "config/types"; @@ -65,3 +66,15 @@ describe("swapRouteIntermediateAssets", () => { expect(swapRouteIntermediateAssets(route("classic", "17"))).toEqual([]); }); }); + +describe("swapQuoteErrorReason", () => { + const apiError = (status: number) => ({ + message: `status ${status}`, + status, + isNetworkError: false, + }); + + it("treats a backend rate limit (429) as unavailable", () => { + expect(swapQuoteErrorReason(apiError(429))).toBe("unavailable"); + }); +}); diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts index 64b5b690b..09b432452 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts @@ -175,4 +175,28 @@ describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { expect(logger.warn).not.toHaveBeenCalled(); }); + + it("does not quote when the sell side falls back to another token", async () => { + const setTokenAmount = jest.fn(); + const { result } = renderHook(() => + useSwapForXlmReserve({ + sourceBalance: undefined, + bestNonXlmClassicBalance: usdcBalance, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, + subentryCount: 0, + swapFee: "0.00001", + setSourceToken: jest.fn(), + setDestinationToken: jest.fn(), + setTokenAmount, + }), + ); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(mockFetchSwapQuotes).not.toHaveBeenCalled(); + expect(setTokenAmount).toHaveBeenCalledWith("0"); + }); }); diff --git a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts index 344a1a20a..0d298ef54 100644 --- a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts +++ b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts @@ -342,6 +342,21 @@ describe("useSwapTransaction", () => { }); }); + describe("setupSwapTransaction — slippage", () => { + it("computes the minimum from the current slippage, not the quote-time minimum", async () => { + const { result } = renderHook(() => useSwapTransaction(baseParams)); + + await act(async () => { + await result.current.setupSwapTransaction(); + }); + + // destinationAmount 2.5 at the settings mock's 0.5% slippage. + expect(mockBuildSwapTransaction).toHaveBeenCalledWith( + expect.objectContaining({ destinationAmountMin: "2.4875000" }), + ); + }); + }); + describe("setupSwapTransaction — quote freshness", () => { it("refuses to build from a quote for other inputs", async () => { const { result } = renderHook(() => diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index 5c65b4e59..1059b0982 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -180,7 +180,7 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { ); }); - it.each([0, 401, 500, 502, 503, 504])( + it.each([0, 401, 429, 500, 502, 503, 504])( "shows swaps temporarily unavailable on %i", async (status) => { mockFetchSwapQuotes.mockRejectedValue(apiError(status)); @@ -339,3 +339,24 @@ describe("swapQuoteKey", () => { ).not.toBe(base); }); }); + +describe("swap quote amount wire format", () => { + beforeEach(() => { + jest.clearAllMocks(); + useSwapStore.getState().resetSwap(); + }); + + it.each([ + ["5.", "5"], + ["1e-7", "0.0000001"], + ])("sends the amount %s as the plain decimal %s", async (amount, wire) => { + mockFetchSwapQuotes.mockResolvedValue({ routes: [], unavailable: [] }); + + await find(amount); + + expect(mockFetchSwapQuotes).toHaveBeenCalledWith( + expect.objectContaining({ amount: wire }), + ); + expect(swapQuoteKey(inputs(amount))).toBe(swapQuoteKey(inputs(wire))); + }); +}); diff --git a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts index a29e143a8..c5527cd5a 100644 --- a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts +++ b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts @@ -38,7 +38,7 @@ export const swapQuoteErrorReason = ( ): "unavailable" | "noRoute" | null => { if (!isApiError(error)) return null; if (error.status === 400) return "noRoute"; - if (error.status === 0 || error.status === 401 || error.status >= 500) { + if ([0, 401, 429].includes(error.status) || error.status >= 500) { return "unavailable"; } return null; diff --git a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts index 6b4c50be0..0dba2ed11 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts @@ -94,7 +94,7 @@ export const useSwapForXlmReserve = ({ let receivedSourceAmount: string | null = null; try { - if (publicKey) { + if (publicKey && isCurrentSourceNonXlmClassic) { const quotes = await fetchSwapQuotes({ network, account: publicKey, diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index f553aece8..fcc021539 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -26,7 +26,11 @@ import { useBalancesStore } from "ducks/balances"; import { useHistoryStore } from "ducks/history"; import { usePricesStore } from "ducks/prices"; import { useRemoteConfigStore } from "ducks/remoteConfig"; -import { SwapPathResult, useSwapStore } from "ducks/swap"; +import { + computeDestMinWithSlippage, + SwapPathResult, + useSwapStore, +} from "ducks/swap"; import { useSwapSettingsStore } from "ducks/swapSettings"; import { SubmitResultCodes, @@ -149,8 +153,11 @@ export const useSwapTransaction = ({ } // Get fresh settings values each time the function is called - const { swapFee: freshSwapFee, swapTimeout: freshSwapTimeout } = - useSwapSettingsStore.getState(); + const { + swapFee: freshSwapFee, + swapTimeout: freshSwapTimeout, + swapSlippage: freshSwapSlippage, + } = useSwapSettingsStore.getState(); // Derive includeTrustline from the swap store's destinationToken. // When requiresTrustline === true the user doesn't yet hold a trustline for the @@ -178,7 +185,12 @@ export const useSwapTransaction = ({ destinationBalance: destinationTokenInput, path: pathResult.path, destinationAmount: pathResult.destinationAmount, - destinationAmountMin: pathResult.destinationAmountMin, + // Slippage isn't in the quote key; the settings sheet can change it + // after quoting. + destinationAmountMin: computeDestMinWithSlippage( + pathResult.destinationAmount, + freshSwapSlippage, + ), transactionFee: freshSwapFee, transactionTimeout: freshSwapTimeout, network, diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index f1ac4ec45..4b7cb3fa9 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -709,9 +709,12 @@ const SwapAmountScreen: React.FC = ({ }, [executeSwap]); const handleSettingsChange = useCallback(() => { + // Review stays disabled until the matching quote lands, so there is + // nothing to rebuild yet. + if (pathResult?.quoteKey !== currentQuoteKey) return; // Settings have changed, rebuild the swap transaction with new values prepareSwapTransaction(false); - }, [prepareSwapTransaction]); + }, [prepareSwapTransaction, pathResult?.quoteKey, currentQuoteKey]); const handleMainButtonPress = useCallback(async () => { // "enter" branch focuses the input — let the keyboard rise naturally. diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index cc779970b..8734818ec 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -90,7 +90,7 @@ const initialState = { buildError: null, }; -const computeDestMinWithSlippage = ( +export const computeDestMinWithSlippage = ( destinationAmount: string, slippage: number, ): string => { @@ -119,7 +119,8 @@ const sendQuoteRequest = (inputs: SwapQuoteInputs) => ({ mode: "send" as const, fromAsset: toSwapWireAsset(inputs.sourceBalance), toAsset: toSwapWireAsset(inputs.destinationBalance), - amount: inputs.sourceAmount, + // The backend accepts only plain decimals; the store can hold "5." or "1e-7". + amount: new BigNumber(inputs.sourceAmount).toFixed(), }); const quoteKeyOf = (r: ReturnType): string => From 0ada1c47c170a2921bc0030b4aff1cbd3402107e Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 22:25:17 -0500 Subject: [PATCH 04/15] feat(swap): show a Quote unavailable line and info sheet on the amount screen Shown after a no-route or swaps-unavailable quote result; tapping opens an information sheet (Figma 3405-86973). The test sheet mock now records each sheet's customContent so a test can find a sheet by its title. Moved here from the C4 branch. Co-Authored-By: Claude Opus 5.5 --- .../SwapScreen/SwapAmountScreen.test.tsx | 100 +++++++++++++++++- .../SwapScreen/screens/SwapAmountScreen.tsx | 36 +++++++ src/i18n/locales/en/translations.json | 4 + src/i18n/locales/pt/translations.json | 4 + 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index 7a9f2b7fe..a93f0d319 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -52,6 +52,8 @@ type SheetRefSpy = { * Returns true when the sheet had an onChange handler. */ fireDismiss: () => boolean; + /** The sheet's customContent element, so tests can call its callbacks. */ + customContent?: React.ReactElement; }; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace, vars-on-top, no-var, no-underscore-dangle @@ -68,8 +70,9 @@ jest.mock("components/BottomSheet", () => { const NoopSheet = (props: { modalRef?: React.RefObject; bottomSheetModalProps?: { onChange?: (index: number) => void }; + customContent?: React.ReactElement; }) => { - const { modalRef, bottomSheetModalProps } = props; + const { modalRef, bottomSheetModalProps, customContent } = props; ReactModule.useImperativeHandle(modalRef, () => { const spy: SheetRefSpy = { present: jest.fn(), @@ -81,6 +84,7 @@ jest.mock("components/BottomSheet", () => { } return false; }, + customContent, }; // eslint-disable-next-line no-underscore-dangle globalThis.__mockSheetRefs.push(spy); @@ -1942,4 +1946,98 @@ describe("SwapAmountScreen", () => { expect(mockSetupSwapTransaction).not.toHaveBeenCalled(); }); }); + + describe("quote unavailable line", () => { + const NO_ROUTE = "No path found between these tokens"; + const UNAVAILABLE = "Swaps are temporarily unavailable"; + const renderScreen = () => + renderWithProviders( + , + ); + + it("shows when no route was found", () => { + setSwapStoreState({ pathError: NO_ROUTE }); + const { getByTestId } = renderScreen(); + + expect(getByTestId("swap-quote-unavailable")).toHaveTextContent( + "Quote unavailable", + ); + }); + + it("shows when the source is unavailable", () => { + setSwapStoreState({ pathError: UNAVAILABLE }); + const { getByTestId } = renderScreen(); + + expect(getByTestId("swap-quote-unavailable")).toBeTruthy(); + }); + + it("is hidden while quoting", () => { + setSwapStoreState({ pathError: NO_ROUTE, isLoadingPath: true }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden when there is a quote", () => { + setSwapStoreState({ + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, + }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden when no amount is entered", () => { + setSwapStoreState({ + pathError: NO_ROUTE, + sourceAmount: "0", + sourceAmountDisplay: "0", + }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden when no destination is chosen", () => { + setSwapStoreState({ pathError: NO_ROUTE, destinationToken: null }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden for an unrelated quote failure", () => { + setSwapStoreState({ + pathError: "Couldn't fetch swap path. Please try again.", + }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("opens the explanation sheet when tapped", async () => { + // eslint-disable-next-line no-underscore-dangle + globalThis.__mockSheetRefs = []; + setSwapStoreState({ pathError: NO_ROUTE }); + const { getByTestId } = renderScreen(); + + await act(async () => { + fireEvent.press(getByTestId("swap-quote-unavailable")); + await Promise.resolve(); + }); + + // eslint-disable-next-line no-underscore-dangle + const sheet = globalThis.__mockSheetRefs.find( + (spy) => + (spy.customContent?.props as { title?: string } | undefined) + ?.title === "Quote unavailable", + ); + expect(sheet?.present).toHaveBeenCalledTimes(1); + const { texts } = sheet?.customContent?.props as { + texts: { value: string }[]; + }; + expect(texts[0].value).toBe( + "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.", + ); + }); + }); }); diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 4b7cb3fa9..74cec68ca 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -4,6 +4,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import BigNumber from "bignumber.js"; import { AmountCard } from "components/AmountCard"; import BottomSheet from "components/BottomSheet"; +import InformationBottomSheet from "components/InformationBottomSheet"; import { PercentageButtons } from "components/PercentageButtons"; import Spinner from "components/Spinner"; import TransactionSettingsBottomSheet from "components/TransactionSettingsBottomSheet"; @@ -129,6 +130,7 @@ const SwapAmountScreen: React.FC = ({ // kept structurally separate so the two can't share a proceed handler. const transactionSecurityWarningBottomSheetModalRef = useRef(null); + const quoteUnavailableSheetRef = useRef(null); const amountInputRef = useRef(null); const { showToast } = useToast(); @@ -398,6 +400,13 @@ const SwapAmountScreen: React.FC = ({ trendingListRef.current?.scrollToOffset({ offset: 0, animated: false }); }, [sourceTokenId, destinationTokenDescriptor?.id, trendingListRef]); + const showQuoteUnavailable = + !isLoadingPath && + !!currentQuoteKey && + new BigNumber(sourceAmount || "0").gt(0) && + (pathError === t("swapScreen.errors.noPathFound") || + pathError === t("swapScreen.errors.swapsUnavailable")); + const { ctaState, ctaLabel, isCtaDisabled } = useSwapCtaState({ sourceBalance, destinationTokenDescriptor, @@ -943,6 +952,20 @@ const SwapAmountScreen: React.FC = ({ placeholderActive={!destinationTokenDescriptor} /> + {showQuoteUnavailable && ( + quoteUnavailableSheetRef.current?.present()} + hitSlop={8} + testID="swap-quote-unavailable" + > + + {t("swapScreen.quoteUnavailable.title")} + + + + )} + @@ -1110,6 +1133,19 @@ const SwapAmountScreen: React.FC = ({ /> } /> + quoteUnavailableSheetRef.current?.dismiss()} + closeTestID="quote-unavailable-close" + /> + } + /> Date: Tue, 10 Nov 2026 22:31:17 -0500 Subject: [PATCH 05/15] fix(swap): show Quote unavailable only for the current inputs The store now records which inputs a quote error is for, so the line no longer shows an error from the previous amount or token during the re-quote debounce. It also hides behind the insufficient-balance and amount-error states, and the sheet dismisses when the line goes away. Co-Authored-By: Claude Opus 5.5 --- .../SwapScreen/SwapAmountScreen.test.tsx | 64 +++++++++++++++++-- __tests__/ducks/swap.findSwapPath.test.ts | 2 + .../SwapScreen/screens/SwapAmountScreen.tsx | 22 +++++-- src/ducks/swap.ts | 9 +++ 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index a93f0d319..4ee081ae0 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -114,6 +114,7 @@ type SwapStoreState = { pathResult: null | { destinationAmount: string; quoteKey: string }; isLoadingPath: boolean; pathError: string | null; + pathErrorQuoteKey?: string | null; setSourceToken: jest.Mock; setDestinationToken: jest.Mock; setEntrySource: jest.Mock; @@ -1950,13 +1951,18 @@ describe("SwapAmountScreen", () => { describe("quote unavailable line", () => { const NO_ROUTE = "No path found between these tokens"; const UNAVAILABLE = "Swaps are temporarily unavailable"; + // A failed quote for the current inputs (swapQuoteKey is mocked). + const failed = (pathError: string) => ({ + pathError, + pathErrorQuoteKey: "quote-key", + }); const renderScreen = () => renderWithProviders( , ); it("shows when no route was found", () => { - setSwapStoreState({ pathError: NO_ROUTE }); + setSwapStoreState({ ...failed(NO_ROUTE) }); const { getByTestId } = renderScreen(); expect(getByTestId("swap-quote-unavailable")).toHaveTextContent( @@ -1965,14 +1971,14 @@ describe("SwapAmountScreen", () => { }); it("shows when the source is unavailable", () => { - setSwapStoreState({ pathError: UNAVAILABLE }); + setSwapStoreState({ ...failed(UNAVAILABLE) }); const { getByTestId } = renderScreen(); expect(getByTestId("swap-quote-unavailable")).toBeTruthy(); }); it("is hidden while quoting", () => { - setSwapStoreState({ pathError: NO_ROUTE, isLoadingPath: true }); + setSwapStoreState({ ...failed(NO_ROUTE), isLoadingPath: true }); const { queryByTestId } = renderScreen(); expect(queryByTestId("swap-quote-unavailable")).toBeNull(); @@ -1989,7 +1995,7 @@ describe("SwapAmountScreen", () => { it("is hidden when no amount is entered", () => { setSwapStoreState({ - pathError: NO_ROUTE, + ...failed(NO_ROUTE), sourceAmount: "0", sourceAmountDisplay: "0", }); @@ -1999,7 +2005,7 @@ describe("SwapAmountScreen", () => { }); it("is hidden when no destination is chosen", () => { - setSwapStoreState({ pathError: NO_ROUTE, destinationToken: null }); + setSwapStoreState({ ...failed(NO_ROUTE), destinationToken: null }); const { queryByTestId } = renderScreen(); expect(queryByTestId("swap-quote-unavailable")).toBeNull(); @@ -2017,7 +2023,7 @@ describe("SwapAmountScreen", () => { it("opens the explanation sheet when tapped", async () => { // eslint-disable-next-line no-underscore-dangle globalThis.__mockSheetRefs = []; - setSwapStoreState({ pathError: NO_ROUTE }); + setSwapStoreState({ ...failed(NO_ROUTE) }); const { getByTestId } = renderScreen(); await act(async () => { @@ -2039,5 +2045,51 @@ describe("SwapAmountScreen", () => { "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later.", ); }); + it("is hidden while the error is for earlier inputs", () => { + setSwapStoreState({ + pathError: NO_ROUTE, + pathErrorQuoteKey: "older-key", + }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden when the amount exceeds the balance", () => { + setSwapStoreState({ + ...failed(NO_ROUTE), + sourceAmount: "999999999", + sourceAmountDisplay: "999999999", + }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("dismisses the sheet when a quote arrives", async () => { + // eslint-disable-next-line no-underscore-dangle + globalThis.__mockSheetRefs = []; + setSwapStoreState(failed(NO_ROUTE)); + const { getByTestId, rerender } = renderScreen(); + await act(async () => { + fireEvent.press(getByTestId("swap-quote-unavailable")); + await Promise.resolve(); + }); + // eslint-disable-next-line no-underscore-dangle + const sheet = globalThis.__mockSheetRefs.find( + (spy) => + (spy.customContent?.props as { title?: string } | undefined) + ?.title === "Quote unavailable", + ); + + setSwapStoreState({ + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, + }); + rerender( + , + ); + + expect(sheet?.dismiss).toHaveBeenCalled(); + }); }); }); diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index 1059b0982..972475a10 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -154,6 +154,7 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { const state = useSwapStore.getState(); expect(state.pathResult).toBeNull(); expect(state.pathError).toBe("swapScreen.errors.noPathFound"); + expect(state.pathErrorQuoteKey).toBe(swapQuoteKey(inputs("10"))); expect(state.swapQuotes).toEqual(quotes); }); @@ -190,6 +191,7 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { const state = useSwapStore.getState(); expect(state.pathResult).toBeNull(); expect(state.pathError).toBe("swapScreen.errors.swapsUnavailable"); + expect(state.pathErrorQuoteKey).toBe(swapQuoteKey(inputs("10"))); }, ); diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 74cec68ca..31191a6ba 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -156,6 +156,7 @@ const SwapAmountScreen: React.FC = ({ pathResult, isLoadingPath, pathError, + pathErrorQuoteKey, setSourceToken, setDestinationToken, setEntrySource, @@ -400,13 +401,6 @@ const SwapAmountScreen: React.FC = ({ trendingListRef.current?.scrollToOffset({ offset: 0, animated: false }); }, [sourceTokenId, destinationTokenDescriptor?.id, trendingListRef]); - const showQuoteUnavailable = - !isLoadingPath && - !!currentQuoteKey && - new BigNumber(sourceAmount || "0").gt(0) && - (pathError === t("swapScreen.errors.noPathFound") || - pathError === t("swapScreen.errors.swapsUnavailable")); - const { ctaState, ctaLabel, isCtaDisabled } = useSwapCtaState({ sourceBalance, destinationTokenDescriptor, @@ -420,6 +414,20 @@ const SwapAmountScreen: React.FC = ({ currentQuoteKey, }); + const showQuoteUnavailable = + !isLoadingPath && + !!currentQuoteKey && + pathErrorQuoteKey === currentQuoteKey && + ctaState.kind !== "insufficient" && + !amountError && + new BigNumber(sourceAmount || "0").gt(0) && + (pathError === t("swapScreen.errors.noPathFound") || + pathError === t("swapScreen.errors.swapsUnavailable")); + + useEffect(() => { + if (!showQuoteUnavailable) quoteUnavailableSheetRef.current?.dismiss(); + }, [showQuoteUnavailable]); + useEffect(() => { if (swapFromTokenId && swapFromTokenSymbol) { // setSourceToken resets the amount on a token change (see the swap diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index 8734818ec..6464246f3 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -54,6 +54,8 @@ interface SwapState { swapQuotes: SwapQuotes | null; isLoadingPath: boolean; pathError: string | null; + /** `swapQuoteKey` of the inputs `pathError` is for. */ + pathErrorQuoteKey: string | null; isBuilding: boolean; buildError: string | null; @@ -86,6 +88,7 @@ const initialState = { swapQuotes: null as SwapQuotes | null, isLoadingPath: false, pathError: null, + pathErrorQuoteKey: null as string | null, isBuilding: false, buildError: null, }; @@ -198,10 +201,12 @@ export const useSwapStore = create((set) => ({ quoteRequestId += 1; const requestId = quoteRequestId; const isStale = () => requestId !== quoteRequestId; + const pathErrorQuoteKey = swapQuoteKey(params); set({ isLoadingPath: true, pathError: null, + pathErrorQuoteKey: null, pathResult: null, swapQuotes: null, }); @@ -226,6 +231,7 @@ export const useSwapStore = create((set) => ({ set({ isLoadingPath: false, pathError: t("swapScreen.errors.customTokenSwapNotSupported"), + pathErrorQuoteKey, }); return; } @@ -250,6 +256,7 @@ export const useSwapStore = create((set) => ({ isLoadingPath: false, swapQuotes, pathError: t(QUOTE_ERROR_KEYS[reason]), + pathErrorQuoteKey, }); return; } @@ -291,6 +298,7 @@ export const useSwapStore = create((set) => ({ set({ isLoadingPath: false, pathError: displayError, + pathErrorQuoteKey, }); } }, @@ -302,6 +310,7 @@ export const useSwapStore = create((set) => ({ swapQuotes: null, destinationAmount: "0", pathError: null, + pathErrorQuoteKey: null, isLoadingPath: false, }); }, From 7580cd7020e7bf6a5a1889349b72c825df5398e0 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 23:21:57 -0500 Subject: [PATCH 06/15] fix(swap): stop re-quoting on slippage changes The path-finding effect re-fired on every slippage change and findSwapPath still computed destinationAmountMin from it, but nothing read that field: useSwapTransaction recomputes the minimum from the stored quote at build time. Changing slippage from the review sheet rebuilt the XDR from the current quote, then 500ms later the debounce nulled the quote and fetched a new one that nothing rebuilt from, so the sheet showed the new amount while the signed transaction carried the old path and minimum. The quote-expired retry path passed slippage for the same reason. Drop slippage from the hook's inputs and effect deps, from the findSwapPath params, and the unused minimum from SwapPathResult. Slippage stays a build-time input only. Co-Authored-By: Claude Fable 5.1 --- .../SwapScreen/hooks/useSwapAmountError.test.ts | 1 - .../screens/SwapScreen/hooks/useSwapCtaState.test.ts | 1 - .../SwapScreen/hooks/useSwapPathFinding.test.ts | 2 -- __tests__/ducks/swap.findSwapPath.test.ts | 5 +---- .../screens/SwapScreen/hooks/useSwapPathFinding.ts | 11 +++++------ .../screens/SwapScreen/hooks/useSwapTransaction.ts | 1 - .../screens/SwapScreen/screens/SwapAmountScreen.tsx | 3 +-- src/ducks/swap.ts | 9 +-------- 8 files changed, 8 insertions(+), 25 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts index fb0738f69..e9020e6de 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts @@ -259,7 +259,6 @@ describe("useSwapAmountError", () => { pathResult: { path: [], destinationAmount: "1", - destinationAmountMin: "0.99", sourceAmount: "1", conversionRate: "1", } as never, diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts index 81c99f513..951cd9b2b 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts @@ -22,7 +22,6 @@ const props = (quoteKey: string, currentQuoteKey: string | null) => ({ pathResult: { sourceAmount: "10", destinationAmount: "2", - destinationAmountMin: "1.98", path: [], conversionRate: "0.2", quoteKey, diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapPathFinding.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapPathFinding.test.ts index 38a4f8cc8..3ae4638e3 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapPathFinding.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapPathFinding.test.ts @@ -27,7 +27,6 @@ const baseProps = (): HookProps => ({ sourceBalance: makeBalance("USDC:GA5..."), destinationTokenForPath: makeBalance("AQUA:GBNZ..."), sourceAmount: "10", - swapSlippage: 2, network: NETWORKS.PUBLIC, publicKey: "GTEST...", amountError: null, @@ -133,7 +132,6 @@ describe("useSwapPathFinding", () => { expect.objectContaining({ sourceBalance: latestSource, sourceAmount: "30", - slippage: 2, }), ); }); diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index 972475a10..c22331b54 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -84,9 +84,7 @@ const inputs = (sourceAmount = "10") => ({ }); const find = (sourceAmount = "10") => - useSwapStore - .getState() - .findSwapPath({ ...inputs(sourceAmount), slippage: 1 }); + useSwapStore.getState().findSwapPath(inputs(sourceAmount)); const apiError = (status: number) => ({ message: `Request failed with status code ${status}`, @@ -133,7 +131,6 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { expect(state.pathResult).toEqual({ sourceAmount: "10", destinationAmount: "2.0000000", - destinationAmountMin: "1.9800000", path: [AQUA], conversionRate: "0.2000000", quoteKey: swapQuoteKey(inputs("10")), diff --git a/src/components/screens/SwapScreen/hooks/useSwapPathFinding.ts b/src/components/screens/SwapScreen/hooks/useSwapPathFinding.ts index 2be8be211..186d89477 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapPathFinding.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapPathFinding.ts @@ -22,7 +22,6 @@ interface UseSwapPathFindingParams { sourceBalance: BalanceItem | undefined; destinationTokenForPath: BalanceItem | undefined; sourceAmount: string; - swapSlippage: number; network: NETWORKS; publicKey: string | undefined; amountError: string | null; @@ -32,7 +31,6 @@ export const useSwapPathFinding = ({ sourceBalance, destinationTokenForPath, sourceAmount, - swapSlippage, network, publicKey, amountError, @@ -52,7 +50,6 @@ export const useSwapPathFinding = ({ sourceBalance, destinationBalance: destinationTokenForPath, sourceAmount, - slippage: swapSlippage, network, publicKey, }); @@ -63,8 +60,11 @@ export const useSwapPathFinding = ({ // Key on the stable `id` (not the object ref) so the 30s balance-polling // re-renders don't re-trigger path-finding. The quote stays frozen until - // the token or amount actually changes. `debouncedFindSwapPath` is a stable - // wrapper that reads the latest objects at call time. + // the token or amount actually changes. Slippage is not an input: the + // build recomputes the minimum received from the stored quote, and a + // re-quote here would replace the quote the review was built from. + // `debouncedFindSwapPath` is a stable wrapper that reads the latest + // objects at call time. useEffect(() => { debouncedFindSwapPath(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -72,7 +72,6 @@ export const useSwapPathFinding = ({ sourceBalance?.id, destinationTokenForPath?.id, sourceAmount, - swapSlippage, network, publicKey, amountError, diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index fcc021539..8f70ae84e 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -605,7 +605,6 @@ export const useSwapTransaction = ({ sourceBalance: latestSource, destinationBalance: latestDest, sourceAmount, - slippage: useSwapSettingsStore.getState().swapSlippage, network, publicKey: account.publicKey, }); diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 31191a6ba..7b6ba8c23 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -119,7 +119,7 @@ const SwapAmountScreen: React.FC = ({ const headerHeight = useHeaderHeight(); const { account } = useGetActiveAccount(); const { network } = useAuthenticationStore(); - const { swapFee, swapSlippage, resetToDefaults } = useSwapSettingsStore(); + const { swapFee, resetToDefaults } = useSwapSettingsStore(); const { overriddenBlockaidResponse } = useDebugStore(); const { isBuilding, resetTransaction } = useTransactionBuilderStore(); const { transactionXDR, transactionHash } = useTransactionBuilderStore(); @@ -281,7 +281,6 @@ const SwapAmountScreen: React.FC = ({ sourceBalance, destinationTokenForPath: destinationForPath, sourceAmount, - swapSlippage, network, publicKey: account?.publicKey, amountError, diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index 6464246f3..532e844aa 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -29,7 +29,6 @@ import { create } from "zustand"; export interface SwapPathResult { sourceAmount: string; destinationAmount: string; - destinationAmountMin: string; path: string[]; conversionRate: string; /** `swapQuoteKey` of the inputs this quote answered. */ @@ -68,7 +67,6 @@ interface SwapState { sourceBalance: PricedBalance; destinationBalance: PricedBalance; sourceAmount: string; - slippage: number; network: NETWORKS; publicKey: string; }) => Promise; @@ -195,8 +193,7 @@ export const useSwapStore = create((set) => ({ }, findSwapPath: async (params) => { - const { sourceBalance, destinationBalance, sourceAmount, slippage } = - params; + const { sourceBalance, destinationBalance, sourceAmount } = params; quoteRequestId += 1; const requestId = quoteRequestId; @@ -264,10 +261,6 @@ export const useSwapStore = create((set) => ({ const pathResult: SwapPathResult = { sourceAmount, destinationAmount: route.toAmount, - destinationAmountMin: computeDestMinWithSlippage( - route.toAmount, - slippage, - ), path: swapRouteIntermediateAssets(route), conversionRate: new BigNumber(route.toAmount) .dividedBy(new BigNumber(sourceAmount)) From 0760995be6459d9699c6cb97c68db2373e981248 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 23:22:53 -0500 Subject: [PATCH 07/15] fix(swap): drop a reserve prefill quote that lands after unmount handleSwapForXlmFromSheet awaited the backend receive quote, which can take seconds where Horizon answered in well under one, and then wrote source, destination and amount unconditionally. Backing out of the swap screen mid-flight let the result re-fill the store that resetSwap had just cleared, so the next swap opened with XLM as the receive token and a stale amount. findSwapPath got a request-id guard in this branch; this call site did not. Guard the writes on the hook still being mounted and on no newer tap having started. Co-Authored-By: Claude Fable 5.1 --- .../hooks/useSwapForXlmReserve.test.ts | 59 ++++++++++++++++++- .../SwapScreen/hooks/useSwapForXlmReserve.ts | 18 +++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts index 09b432452..a48fb8e27 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts @@ -3,7 +3,7 @@ import BigNumber from "bignumber.js"; import { useSwapForXlmReserve } from "components/screens/SwapScreen/hooks/useSwapForXlmReserve"; import { NETWORKS } from "config/constants"; import { logger } from "config/logger"; -import { TokenTypeWithCustomToken } from "config/types"; +import { SwapQuotes, TokenTypeWithCustomToken } from "config/types"; import { type HeldBalanceItem } from "hooks/useBalancesList"; import { fetchSwapQuotes } from "services/backend"; @@ -176,6 +176,63 @@ describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it("does not write the store when the quote lands after unmount", async () => { + let resolveQuote: (q: SwapQuotes) => void = () => {}; + mockFetchSwapQuotes.mockReturnValue( + new Promise((resolve) => { + resolveQuote = resolve; + }), + ); + const setSourceToken = jest.fn(); + const setDestinationToken = jest.fn(); + const setTokenAmount = jest.fn(); + const { result, unmount } = renderHook(() => + useSwapForXlmReserve({ + sourceBalance: usdcBalance, + bestNonXlmClassicBalance: undefined, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, + subentryCount: 0, + swapFee: "0.00001", + setSourceToken, + setDestinationToken, + setTokenAmount, + }), + ); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.handleSwapForXlmFromSheet(); + }); + // The user backs out of the swap screen while the quote is in flight; + // the screen's unmount resets the store, which this must not re-fill. + unmount(); + await act(async () => { + resolveQuote({ + routes: [ + { + source: "classic", + fromAmount: "0.0912345", + toAmount: "0.5", + paths: [ + { + share: "1", + hops: [{ venue: "stellar-dex", from: USDC, to: "XLM" }], + }, + ], + fees: [], + }, + ], + unavailable: [], + }); + await pending; + }); + + expect(setDestinationToken).not.toHaveBeenCalled(); + expect(setSourceToken).not.toHaveBeenCalled(); + expect(setTokenAmount).not.toHaveBeenCalled(); + }); + it("does not quote when the sell side falls back to another token", async () => { const setTokenAmount = jest.fn(); const { result } = renderHook(() => diff --git a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts index 0dba2ed11..07c0cdf43 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts @@ -15,7 +15,7 @@ import { TokenTypeWithCustomToken } from "config/types"; import { isNativeAssetId } from "helpers/assetIdentity"; import { calculateSpendableAmount } from "helpers/balances"; import { type HeldBalanceItem } from "hooks/useBalancesList"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { fetchSwapQuotes } from "services/backend"; interface UseSwapForXlmReserveParams { @@ -57,6 +57,17 @@ export const useSwapForXlmReserve = ({ setTokenAmount, }: UseSwapForXlmReserveParams) => { const xlmReserveBottomSheetRef = useRef(null); + // The receive quote can take seconds. A result that lands after the screen + // unmounted (resetSwap has run) or after a newer tap must not write the + // store. + const isMountedRef = useRef(true); + const quoteRequestRef = useRef(0); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); const isCurrentSourceNonXlmClassic = !!sourceBalance && @@ -92,6 +103,8 @@ export const useSwapForXlmReserve = ({ : undefined; if (!sellTokenCode || !sellIssuer) return; + quoteRequestRef.current += 1; + const requestId = quoteRequestRef.current; let receivedSourceAmount: string | null = null; try { if (publicKey && isCurrentSourceNonXlmClassic) { @@ -125,6 +138,9 @@ export const useSwapForXlmReserve = ({ error, ); } + if (!isMountedRef.current || requestId !== quoteRequestRef.current) { + return; + } setDestinationToken({ id: NATIVE_TOKEN_CODE, From 93fcd140f7bb4c152ae493982f51004272b59657 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 23:24:25 -0500 Subject: [PATCH 08/15] fix(swap): classify every non-400 quote failure as unavailable swapQuoteErrorReason mapped 400 to no-route and only 0, 401, 429 and 5xx to unavailable; anything else fell through to the generic "Couldn't fetch swap path" with no Quote unavailable sheet. Until freighter-backend-v2#178 is live in prd the route 404s, so the window both PR descriptions call out showed the generic message instead of "Swaps are temporarily unavailable". The extension already treats every non-400 failure as unavailable; match it. A 200 whose body isn't a quotes envelope threw a plain Error and landed in the same generic bucket. Give it a named error class and classify it as unavailable too. Co-Authored-By: Claude Fable 5.1 --- .../helpers/selectSwapRoute.test.ts | 24 +++++++++++++++++++ __tests__/ducks/swap.findSwapPath.test.ts | 16 ++++++++++++- __tests__/services/backend.swapQuotes.test.ts | 2 +- .../SwapScreen/helpers/selectSwapRoute.ts | 14 +++++++---- src/services/backend.ts | 10 +++++++- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts index 9e8ead9ea..e6ca5d544 100644 --- a/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts +++ b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts @@ -77,4 +77,28 @@ describe("swapQuoteErrorReason", () => { it("treats a backend rate limit (429) as unavailable", () => { expect(swapQuoteErrorReason(apiError(429))).toBe("unavailable"); }); + + it("treats a 400 as no route", () => { + expect(swapQuoteErrorReason(apiError(400))).toBe("noRoute"); + }); + + // The route 404s while prd runs a backend image without it, and an + // ingress can 403; both are outages from the user's side, not bad input. + it.each([0, 401, 403, 404, 500, 502, 503])( + "treats a %i as unavailable", + (status) => { + expect(swapQuoteErrorReason(apiError(status))).toBe("unavailable"); + }, + ); + + it("treats a malformed quotes body as unavailable", () => { + const shapeError = Object.assign(new Error("Unexpected shape"), { + name: "SwapQuotesResponseError", + }); + expect(swapQuoteErrorReason(shapeError)).toBe("unavailable"); + }); + + it("leaves any other thrown error to the caller", () => { + expect(swapQuoteErrorReason(new Error("debug failure"))).toBeNull(); + }); }); diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index c22331b54..1634abda0 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -178,7 +178,7 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { ); }); - it.each([0, 401, 429, 500, 502, 503, 504])( + it.each([0, 401, 403, 404, 429, 500, 502, 503, 504])( "shows swaps temporarily unavailable on %i", async (status) => { mockFetchSwapQuotes.mockRejectedValue(apiError(status)); @@ -192,6 +192,20 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { }, ); + it("shows swaps temporarily unavailable on a malformed quotes body", async () => { + mockFetchSwapQuotes.mockRejectedValue( + Object.assign(new Error("Unexpected swap quotes response shape"), { + name: "SwapQuotesResponseError", + }), + ); + + await find(); + + expect(useSwapStore.getState().pathError).toBe( + "swapScreen.errors.swapsUnavailable", + ); + }); + it("ignores a response that lands after a newer request was started", async () => { let resolveFirst: (q: SwapQuotes) => void = () => {}; mockFetchSwapQuotes diff --git a/__tests__/services/backend.swapQuotes.test.ts b/__tests__/services/backend.swapQuotes.test.ts index 1af55adf4..8e3939d60 100644 --- a/__tests__/services/backend.swapQuotes.test.ts +++ b/__tests__/services/backend.swapQuotes.test.ts @@ -98,6 +98,6 @@ describe("fetchSwapQuotes", () => { toAsset: USDC, amount: "100", }), - ).rejects.toThrow(/swap quotes/); + ).rejects.toMatchObject({ name: "SwapQuotesResponseError" }); }); }); diff --git a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts index c5527cd5a..ea5a73638 100644 --- a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts +++ b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts @@ -32,14 +32,18 @@ export const missingSwapRouteReason = ( : "noRoute"; }; -/** Maps a failed quote request; null leaves it to the caller's generic handling. */ +/** + * Maps a failed quote request. A 400 is the request (bad input, an unfunded + * account); any other HTTP failure, and a body the parser rejected, means the + * backend can't quote right now. Null leaves a non-HTTP error (the debug + * failure switch) to the caller's generic handling. + */ export const swapQuoteErrorReason = ( error: unknown, ): "unavailable" | "noRoute" | null => { - if (!isApiError(error)) return null; - if (error.status === 400) return "noRoute"; - if ([0, 401, 429].includes(error.status) || error.status >= 500) { + if (error instanceof Error && error.name === "SwapQuotesResponseError") { return "unavailable"; } - return null; + if (!isApiError(error)) return null; + return error.status === 400 ? "noRoute" : "unavailable"; }; diff --git a/src/services/backend.ts b/src/services/backend.ts index 2c342f9d3..0eda8354e 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -1023,6 +1023,14 @@ export interface FetchSwapQuotesParams { amount: string; } +/** A 200 whose body is not a quotes envelope; classified as an outage. */ +export class SwapQuotesResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "SwapQuotesResponseError"; + } +} + /** * Quotes a swap from every backend source, best route first. A 200 with empty * `routes` means no source can quote the pair; HTTP failures reject with the @@ -1039,7 +1047,7 @@ export const fetchSwapQuotes = async ({ ); if (!Array.isArray(data?.data?.routes)) { - throw new Error("Unexpected swap quotes response shape"); + throw new SwapQuotesResponseError("Unexpected swap quotes response shape"); } return data.data; From 793ac667b5896b53ff809e678d62490b6d9af4e9 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 23:25:08 -0500 Subject: [PATCH 09/15] fix(swap): report a quote outage once, and 400s as warnings findSwapPath logged every failure at error level, which reaches Sentry in production, and it runs once per debounced keystroke. A backend outage produced one event per amount edit per user, and a 400 for bad input or an unfunded account was reported as a fault. Report an outage once until a quote succeeds or the screen is left, whether it arrives as a thrown non-400 or as a 200 with the classic source down. Log 400s at warn. Anything the classifier doesn't recognise still reports every time. Matches the extension. Co-Authored-By: Claude Fable 5.1 --- __tests__/ducks/swap.findSwapPath.test.ts | 63 +++++++++++++++++++++++ src/ducks/swap.ts | 45 +++++++++++++--- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index 1634abda0..8bfcd21ca 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -176,6 +176,9 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { expect(useSwapStore.getState().pathError).toBe( "swapScreen.errors.noPathFound", ); + // Bad input is the user's state, not something to page on. + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); }); it.each([0, 401, 403, 404, 429, 500, 502, 503, 504])( @@ -259,6 +262,66 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { expect(logger.error).toHaveBeenCalled(); }); + it("reports an outage once until a quote succeeds, not once per keystroke", async () => { + mockFetchSwapQuotes.mockRejectedValue(apiError(503)); + await find("10"); + await find("11"); + await find("12"); + expect(logger.error).toHaveBeenCalledTimes(1); + + // A 200 with classic down is the same outage. + mockFetchSwapQuotes.mockResolvedValue({ + routes: [], + unavailable: [{ source: "classic", reason: "timeout" }], + }); + await find("13"); + expect(logger.error).toHaveBeenCalledTimes(1); + + mockFetchSwapQuotes.mockResolvedValue({ + routes: [classicRoute], + unavailable: [], + }); + await find("14"); + mockFetchSwapQuotes.mockRejectedValue(apiError(503)); + await find("15"); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + + it("reports a new outage after the backend recovered with no route", async () => { + mockFetchSwapQuotes.mockRejectedValue(apiError(503)); + await find("10"); + mockFetchSwapQuotes.mockResolvedValue({ + routes: [], + unavailable: [{ source: "classic", reason: "no_route" }], + }); + await find("11"); + mockFetchSwapQuotes.mockRejectedValue(apiError(503)); + await find("12"); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + + it("reports a hopless classic route as a backend fault, shown as no path", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [{ ...classicRoute, paths: [{ share: "1", hops: [] }] }], + unavailable: [], + }); + + await find(); + + expect(useSwapStore.getState().pathError).toBe( + "swapScreen.errors.noPathFound", + ); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it("reports again on the next screen visit", async () => { + mockFetchSwapQuotes.mockRejectedValue(apiError(503)); + await find("10"); + useSwapStore.getState().resetSwap(); + await find("10"); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + it.each(["error", "timeout", "rate_limited"])( "shows swaps temporarily unavailable and reports when classic is unavailable with %s", async (reason) => { diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index 532e844aa..79432f599 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -148,6 +148,9 @@ const QUOTE_ERROR_KEYS = { // Quotes can take seconds, so a slower earlier request must not overwrite a // newer one or a cleared path. let quoteRequestId = 0; +// The quote re-fires per debounced keystroke, so an outage is reported to +// Sentry once until a quote succeeds or the screen is left. +let outageReported = false; export const useSwapStore = create((set) => ({ ...initialState, @@ -225,6 +228,7 @@ export const useSwapStore = create((set) => ({ isContractId(destinationBalance.id ?? "")); if (isSourceCustom || isDestCustom) { + outageReported = false; set({ isLoadingPath: false, pathError: t("swapScreen.errors.customTokenSwapNotSupported"), @@ -243,11 +247,25 @@ export const useSwapStore = create((set) => ({ if (!route) { const reason = missingSwapRouteReason(swapQuotes); if (reason === "unavailable") { - logger.error( - "SwapStore", - "Swap source unavailable", - swapQuotes.unavailable, - ); + if (!outageReported) { + outageReported = true; + logger.error( + "SwapStore", + "Swap source unavailable", + swapQuotes.unavailable, + ); + } + } else { + outageReported = false; + if (swapQuotes.routes.some((r) => r.source === "classic")) { + // A classic route was quoted but selectSwapRoute rejected it + // (no hops): a backend bug, not a pair without liquidity. + logger.error( + "SwapStore", + "Malformed classic route", + swapQuotes.routes, + ); + } } set({ isLoadingPath: false, @@ -268,6 +286,7 @@ export const useSwapStore = create((set) => ({ quoteKey: quoteKeyOf(request), }; + outageReported = false; set({ isLoadingPath: false, swapQuotes, @@ -280,9 +299,19 @@ export const useSwapStore = create((set) => ({ const errorMessage = (error as { message?: string } | null)?.message ?? String(error); - logger.error("SwapStore", "Failed to find swap path", error); - const reason = swapQuoteErrorReason(error); + if (reason === "noRoute") { + // A 400 is the request (bad input, an unfunded account), not a fault. + outageReported = false; + logger.warn("SwapStore", "Swap quote rejected", error); + } else if (reason === "unavailable") { + if (!outageReported) { + outageReported = true; + logger.error("SwapStore", "Failed to find swap path", error); + } + } else { + logger.error("SwapStore", "Failed to find swap path", error); + } const fallbackError = __DEV__ ? errorMessage : t("swapScreen.errors.pathFindFailed"); @@ -298,6 +327,7 @@ export const useSwapStore = create((set) => ({ clearPath: () => { quoteRequestId += 1; + outageReported = false; set({ pathResult: null, swapQuotes: null, @@ -310,6 +340,7 @@ export const useSwapStore = create((set) => ({ resetSwap: () => { quoteRequestId += 1; + outageReported = false; set(initialState); }, })); From 91e18bf41f63adeaec5c4270d7b85341378f8831 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 23:26:42 -0500 Subject: [PATCH 10/15] fix(swap): don't toast the quote errors the screen already shows inline The three-second path-error toast still fired for no-route and swaps-unavailable, on top of the persistent "Quote unavailable" row and sheet the amount screen now renders for exactly those two, so the same failure surfaced twice and the toast re-fired over the row on every re-quote. Skip the toast for the two inline-handled errors; any other path error still toasts. Co-Authored-By: Claude Fable 5.1 --- .../hooks/useSwapAmountError.test.ts | 21 +++++++++++++++++++ .../SwapScreen/hooks/useSwapAmountError.ts | 8 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts index e9020e6de..61f20723b 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts @@ -238,6 +238,27 @@ describe("useSwapAmountError", () => { expect(pathErrorCalls.length).toBe(0); }); + // The amount screen renders these two as a persistent "Quote unavailable" + // row with its own sheet; a toast on top would say the same thing twice. + it.each([ + "swapScreen.errors.noPathFound", + "swapScreen.errors.swapsUnavailable", + ])("does NOT toast %s, which the screen shows inline", (key) => { + renderHook(() => + useSwapAmountError({ + ...baseProps, + pathError: `${key}::{}`, + }), + ); + + const pathErrorCalls = mockShowToast.mock.calls.filter( + ([arg]) => + (arg as { toastId?: string }).toastId === + SWAP_TOAST_IDS.SWAP_PATH_ERROR, + ); + expect(pathErrorCalls.length).toBe(0); + }); + it("dismisses the path-error toast when a subsequent pathResult arrives without a pathError", () => { const initialProps: HookProps = { ...baseProps, diff --git a/src/components/screens/SwapScreen/hooks/useSwapAmountError.ts b/src/components/screens/SwapScreen/hooks/useSwapAmountError.ts index 3c6fa2535..4d81e8d06 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapAmountError.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapAmountError.ts @@ -153,8 +153,13 @@ export const useSwapAmountError = ({ useEffect(() => { const hasAmount = sourceAmount && Number(sourceAmount) > 0; const hasDestinationToken = !!destinationTokenDescriptor; + // The amount screen shows these two as a persistent "Quote unavailable" + // row with its own sheet, so a toast would say the same thing twice. + const shownInline = + pathError === t("swapScreen.errors.noPathFound") || + pathError === t("swapScreen.errors.swapsUnavailable"); - if (pathError && hasAmount && hasDestinationToken) { + if (pathError && !shownInline && hasAmount && hasDestinationToken) { setActiveError({ message: pathError, toastId: SWAP_TOAST_IDS.SWAP_PATH_ERROR, @@ -173,6 +178,7 @@ export const useSwapAmountError = ({ sourceAmount, destinationTokenDescriptor, activeError?.toastId, + t, ]); return { amountError, setActiveError }; From ff9f4af20ddd814052f6eba74f57f8809b7d787b Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 23:26:42 -0500 Subject: [PATCH 11/15] fix(swap): treat a classic route with no hops as no route swapRouteIntermediateAssets turned an empty hop chain into an empty path, and findSwapPath still built a path result from the route's amounts, so a malformed classic route was offered as a quote and submitted as a direct pathPaymentStrictSend that can fail on-chain with the user paying the fee. Select only classic routes with hops; the rest reads as no route, as the extension already does, and the duck reports the malformed route to Sentry since it is a backend fault rather than a pair without liquidity. A real route always has at least one hop: freighter-backend-v2 builds classic hops from [from, ...path, to] in classicRoute (internal/services/swap/classic.go), so this guards against a regression there, not a normal reply. Co-Authored-By: Claude Fable 5.1 --- .../SwapScreen/helpers/selectSwapRoute.test.ts | 16 ++++++++++++++++ __tests__/ducks/swap.findSwapPath.test.ts | 13 +++++++++++++ .../SwapScreen/helpers/selectSwapRoute.ts | 8 ++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts index e6ca5d544..4d258e53d 100644 --- a/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts +++ b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts @@ -33,6 +33,22 @@ describe("selectSwapRoute", () => { ).toBeNull(); }); + // Building a direct payment from a hopless route could fail on-chain with + // the user paying the fee. + it("returns null for a classic route with no hops", () => { + const noHops: SwapRoute = { + ...route("classic", "17"), + paths: [{ share: "1", hops: [] }], + }; + expect(selectSwapRoute({ routes: [noHops], unavailable: [] })).toBeNull(); + expect( + selectSwapRoute({ + routes: [{ ...route("classic", "17"), paths: [] }], + unavailable: [], + }), + ).toBeNull(); + }); + it("returns null for empty routes", () => { expect( selectSwapRoute({ diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index 8bfcd21ca..97a27f97e 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -155,6 +155,19 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { expect(state.swapQuotes).toEqual(quotes); }); + it("shows no path for a classic route with no hops", async () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [{ ...classicRoute, paths: [{ share: "1", hops: [] }] }], + unavailable: [], + }); + + await find(); + + const state = useSwapStore.getState(); + expect(state.pathResult).toBeNull(); + expect(state.pathError).toBe("swapScreen.errors.noPathFound"); + }); + it("shows no path when only a non-classic route was quoted", async () => { mockFetchSwapQuotes.mockResolvedValue({ routes: [lifiRoute], diff --git a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts index ea5a73638..11b8ae862 100644 --- a/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts +++ b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts @@ -4,10 +4,14 @@ import { isApiError } from "services/apiFactory"; /** * The route the swap uses. Classic only, not `routes[0]`, while the client * still builds the transaction itself and can build only a classic path - * payment. + * payment. A classic route with no hops is treated as no route rather than + * built as a direct payment that can fail on-chain. */ export const selectSwapRoute = (quotes: SwapQuotes): SwapRoute | null => - quotes.routes.find((route) => route.source === "classic") ?? null; + quotes.routes.find( + (route) => + route.source === "classic" && (route.paths[0]?.hops.length ?? 0) > 0, + ) ?? null; /** * A classic route's path-payment `path`: the assets between the sold and the From 4f479529dd1fc97af2f86ed2c337daa18f1be95b Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 10:28:51 -0500 Subject: [PATCH 12/15] fix(swap): keep "Review swap" on the CTA when the quote failed The Varial design (Transactions page, "Quote unavailble" frame) shows a disabled "Review swap" with the reason on the "Quote unavailable" line and sheet. A failed quote fell through to the "enter" state, so the disabled button read "Enter an amount" with an amount already typed. The pathError gate already disables it; only the label changes. Co-Authored-By: Claude Fable 5.1 --- .../SwapScreen/hooks/useSwapCtaState.test.ts | 16 ++++++++++++++++ .../screens/SwapScreen/hooks/useSwapCtaState.ts | 9 ++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts index 951cd9b2b..86dd10cde 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts @@ -44,3 +44,19 @@ describe("useSwapCtaState — quote freshness", () => { expect(result.current.ctaState.kind).toBe("loading"); }); }); + +describe("useSwapCtaState — failed quote", () => { + it("keeps Review swap, disabled, when the quote failed (the Quote unavailable line explains)", () => { + const { result } = renderHook(() => + useSwapCtaState({ + ...props("k1", "k1"), + pathResult: null, + pathError: "swapScreen.errors.noPathFound", + }), + ); + + expect(result.current.ctaState.kind).toBe("review"); + expect(result.current.ctaLabel).toBe("swapScreen.cta.review"); + expect(result.current.isCtaDisabled).toBe(true); + }); +}); diff --git a/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts b/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts index cb4bc41fc..aa4000810 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts @@ -13,6 +13,7 @@ import { useMemo } from "react"; * insufficient amount exceeds spendable ──► disabled * loading path-finding in flight ──► spinner * review path resolved, amount valid ──► open Review sheet + * (also the disabled label when the quote failed) */ export type SwapCtaState = | { kind: "select"; missingSide: "source" | "destination" } @@ -84,9 +85,11 @@ export const useSwapCtaState = ({ : { kind: "loading" }; } - // Path-finding finished without a result (or threw) — keep the user on - // the amount step. The persistent toast (set up by the screen) already - // surfaces pathError when present. + // A failed quote keeps the Review label (design); the pathError gate + // below disables it and the Quote unavailable line explains why. + if (pathError) return { kind: "review" }; + + // No quote yet for these inputs: keep the user on the amount step. return { kind: "enter" }; }, [ sourceBalance, From 7d8eb6920ed0dabd88947a7dc1957078113a7aba Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 12:09:39 -0500 Subject: [PATCH 13/15] fix(swap): drop a reserve prefill quote whose inputs changed in flight The unmount and newer-tap guards did not cover the user dismissing the sheet and changing the sell token, account or network while the receive quote was in flight: the old answer then set XLM as the destination and prefilled an amount for the previous inputs. The answer is now applied only when the inputs it was asked for are still current. The extension's prefill gets the same identity check. Co-Authored-By: Claude Fable 5.1 --- .../hooks/useSwapForXlmReserve.test.ts | 138 ++++++++++++++++++ .../SwapScreen/hooks/useSwapForXlmReserve.ts | 16 +- 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts index a48fb8e27..649fba786 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts @@ -44,6 +44,28 @@ const usdcBalance = { available: new BigNumber("100"), } as unknown as HeldBalanceItem; +const AQUA_ISSUER = "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; +const aquaBalance = { + id: `AQUA:${AQUA_ISSUER}`, + tokenCode: "AQUA", + tokenType: TokenTypeWithCustomToken.CREDIT_ALPHANUM4, + token: { + code: "AQUA", + issuer: { key: AQUA_ISSUER }, + type: "credit_alphanum4", + }, + total: new BigNumber("100"), + available: new BigNumber("100"), +} as unknown as HeldBalanceItem; + +const xlmBalance = { + id: "XLM", + tokenCode: "XLM", + tokenType: TokenTypeWithCustomToken.NATIVE, + total: new BigNumber("100"), + available: new BigNumber("100"), +} as unknown as HeldBalanceItem; + const renderReserveHook = () => { const setSourceToken = jest.fn(); const setDestinationToken = jest.fn(); @@ -233,6 +255,122 @@ describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { expect(setTokenAmount).not.toHaveBeenCalled(); }); + it("drops the quote when the sell token changes while it is in flight", async () => { + let resolveQuote: (q: SwapQuotes) => void = () => {}; + mockFetchSwapQuotes.mockReturnValue( + new Promise((resolve) => { + resolveQuote = resolve; + }), + ); + const setSourceToken = jest.fn(); + const setDestinationToken = jest.fn(); + const setTokenAmount = jest.fn(); + const props = (sourceBalance: HeldBalanceItem) => ({ + sourceBalance, + bestNonXlmClassicBalance: undefined, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, + subentryCount: 0, + swapFee: "0.00001", + setSourceToken, + setDestinationToken, + setTokenAmount, + }); + const { result, rerender } = renderHook( + (p: ReturnType) => useSwapForXlmReserve(p), + { initialProps: props(usdcBalance) }, + ); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.handleSwapForXlmFromSheet(); + }); + // The user dismisses the sheet and picks another sell token before the + // USDC quote lands. + rerender(props(aquaBalance)); + await act(async () => { + resolveQuote({ + routes: [ + { + source: "classic", + fromAmount: "0.0912345", + toAmount: "0.5", + paths: [ + { + share: "1", + hops: [{ venue: "stellar-dex", from: USDC, to: "XLM" }], + }, + ], + fees: [], + }, + ], + unavailable: [], + }); + await pending; + }); + + expect(setDestinationToken).not.toHaveBeenCalled(); + expect(setTokenAmount).not.toHaveBeenCalled(); + }); + + it("drops the quote when the source becomes XLM while it is in flight, even if the fallback is the same token", async () => { + let resolveQuote: (q: SwapQuotes) => void = () => {}; + mockFetchSwapQuotes.mockReturnValue( + new Promise((resolve) => { + resolveQuote = resolve; + }), + ); + const setSourceToken = jest.fn(); + const setDestinationToken = jest.fn(); + const setTokenAmount = jest.fn(); + const props = (sourceBalance: HeldBalanceItem) => ({ + sourceBalance, + // USDC is also the best classic fallback, so the resolved sell token + // is USDC before and after the switch. + bestNonXlmClassicBalance: usdcBalance, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, + subentryCount: 0, + swapFee: "0.00001", + setSourceToken, + setDestinationToken, + setTokenAmount, + }); + const { result, rerender } = renderHook( + (p: ReturnType) => useSwapForXlmReserve(p), + { initialProps: props(usdcBalance) }, + ); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.handleSwapForXlmFromSheet(); + }); + rerender(props(xlmBalance)); + await act(async () => { + resolveQuote({ + routes: [ + { + source: "classic", + fromAmount: "0.0912345", + toAmount: "0.5", + paths: [ + { + share: "1", + hops: [{ venue: "stellar-dex", from: USDC, to: "XLM" }], + }, + ], + fees: [], + }, + ], + unavailable: [], + }); + await pending; + }); + + expect(setDestinationToken).not.toHaveBeenCalled(); + expect(setTokenAmount).not.toHaveBeenCalled(); + }); + it("does not quote when the sell side falls back to another token", async () => { const setTokenAmount = jest.fn(); const { result } = renderHook(() => diff --git a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts index 07c0cdf43..42b4c8f45 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts @@ -58,8 +58,8 @@ export const useSwapForXlmReserve = ({ }: UseSwapForXlmReserveParams) => { const xlmReserveBottomSheetRef = useRef(null); // The receive quote can take seconds. A result that lands after the screen - // unmounted (resetSwap has run) or after a newer tap must not write the - // store. + // unmounted (resetSwap has run), after a newer tap, or after the sell + // token, account or network changed must not write the store. const isMountedRef = useRef(true); const quoteRequestRef = useRef(0); useEffect(() => { @@ -74,6 +74,11 @@ export const useSwapForXlmReserve = ({ !isNativeAssetId(sourceBalance.id) && (sourceBalance.tokenType === TokenTypeWithCustomToken.CREDIT_ALPHANUM4 || sourceBalance.tokenType === TokenTypeWithCustomToken.CREDIT_ALPHANUM12); + // Keyed on the source itself, not the resolved sell token: switching the + // source to XLM must drop an in-flight quote even when the fallback + // resolves to the same token that was quoted. + const quoteInputsRef = useRef(""); + quoteInputsRef.current = `${sourceBalance?.id}|${publicKey}|${network}`; const canOfferSwapToXlm = isCurrentSourceNonXlmClassic || !!bestNonXlmClassicBalance; @@ -105,6 +110,7 @@ export const useSwapForXlmReserve = ({ quoteRequestRef.current += 1; const requestId = quoteRequestRef.current; + const quotedFor = quoteInputsRef.current; let receivedSourceAmount: string | null = null; try { if (publicKey && isCurrentSourceNonXlmClassic) { @@ -138,7 +144,11 @@ export const useSwapForXlmReserve = ({ error, ); } - if (!isMountedRef.current || requestId !== quoteRequestRef.current) { + if ( + !isMountedRef.current || + requestId !== quoteRequestRef.current || + quotedFor !== quoteInputsRef.current + ) { return; } From 904e6bc4cdcdd75f1b1fda18800fa665c011f46d Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 12:10:56 -0500 Subject: [PATCH 14/15] fix(swap): report reserve prefill quote failures at error level The extension reports every failed "Swap for 0.5 XLM" quote to Sentry, 400 included, because the request is built from the wallet's own balances; this hook only warned, so the same failure was invisible on mobile. A plain no-route answer is still not reported on either. Co-Authored-By: Claude Fable 5.1 --- .../hooks/useSwapForXlmReserve.test.ts | 21 ++++++++++++++++++- .../SwapScreen/hooks/useSwapForXlmReserve.ts | 9 ++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts index 649fba786..7e4b8f63b 100644 --- a/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts @@ -167,6 +167,7 @@ describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { expect(setDestinationToken).toHaveBeenCalledWith( expect.objectContaining({ id: "XLM" }), ); + expect(logger.error).toHaveBeenCalled(); }); it("reports a classic outage the way a failed quote is reported", async () => { @@ -181,7 +182,24 @@ describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { }); expect(setTokenAmount).not.toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalled(); + // Same level as the extension: a tap-driven quote failure is reported. + expect(logger.error).toHaveBeenCalled(); + }); + + it("reports a rejected receive quote (400) like any other failure", async () => { + mockFetchSwapQuotes.mockRejectedValue({ + message: "Request failed with status code 400", + status: 400, + isNetworkError: false, + }); + const { result, setDestinationToken } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(logger.error).toHaveBeenCalled(); + expect(setDestinationToken).toHaveBeenCalled(); }); it("does not report a plain no-route answer", async () => { @@ -196,6 +214,7 @@ describe("useSwapForXlmReserve — 0.5 XLM estimate", () => { }); expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); }); it("does not write the store when the quote lands after unmount", async () => { diff --git a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts index 42b4c8f45..8aee2d584 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts @@ -127,7 +127,7 @@ export const useSwapForXlmReserve = ({ !receivedSourceAmount && missingSwapRouteReason(quotes) === "unavailable" ) { - logger.warn( + logger.error( "useSwapForXlmReserve.handleSwapForXlmFromSheet", "receive quote source unavailable", quotes.unavailable, @@ -136,9 +136,10 @@ export const useSwapForXlmReserve = ({ } } catch (error) { // No path / network error — fall back to setting source+dest without - // a pre-filled amount so the user can still adjust manually. Expected - // and handled, so warn (not error → no red LogBox / Sentry issue). - logger.warn( + // a pre-filled amount so the user can still adjust manually. Reported + // per tap, 400 included, as the extension does: the request was built + // from the app's own balances, so a rejection is worth seeing. + logger.error( "useSwapForXlmReserve.handleSwapForXlmFromSheet", "receive quote failed", error, From 30063ac07faa24dda47d6d609e65bb0a40f37df0 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 12:11:45 -0500 Subject: [PATCH 15/15] fix(swap): clear the receive amount when a quote fails A failed quote cleared pathResult but left destinationAmount, which the receive card renders directly, so the previous quote's amount sat next to "Quote unavailable". Every failure branch now resets it. The amount stays on the card while the next quote is in flight, which is what the design shows; the extension hides it behind a shimmer there and resets it on failure the same way. Co-Authored-By: Claude Fable 5.1 --- __tests__/ducks/swap.findSwapPath.test.ts | 28 +++++++++++++++++++++++ src/ducks/swap.ts | 6 +++++ 2 files changed, 34 insertions(+) diff --git a/__tests__/ducks/swap.findSwapPath.test.ts b/__tests__/ducks/swap.findSwapPath.test.ts index 97a27f97e..ee2aada5d 100644 --- a/__tests__/ducks/swap.findSwapPath.test.ts +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -139,6 +139,34 @@ describe("useSwapStore.findSwapPath — backend quotes", () => { expect(state.swapQuotes).toEqual(quotes); }); + it("keeps the previous receive amount while quoting and clears it when the quote fails", async () => { + mockFetchSwapQuotes.mockResolvedValueOnce({ + routes: [classicRoute], + unavailable: [], + }); + await find("10"); + const quoted = useSwapStore.getState().destinationAmount; + expect(quoted).not.toBe("0"); + + let reject: (e: unknown) => void = () => {}; + mockFetchSwapQuotes.mockReturnValueOnce( + new Promise((_, r) => { + reject = r; + }), + ); + const pending = find("11"); + // The design keeps the last amount on the card while the next quote is + // in flight; only a failure clears it. + expect(useSwapStore.getState().destinationAmount).toBe(quoted); + + reject(apiError(503)); + await pending; + + const state = useSwapStore.getState(); + expect(state.destinationAmount).toBe("0"); + expect(state.pathError).toBe("swapScreen.errors.swapsUnavailable"); + }); + it("shows no path when the backend returns empty routes", async () => { const quotes: SwapQuotes = { routes: [], diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index 79432f599..35b1c46a5 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -231,6 +231,7 @@ export const useSwapStore = create((set) => ({ outageReported = false; set({ isLoadingPath: false, + destinationAmount: "0", pathError: t("swapScreen.errors.customTokenSwapNotSupported"), pathErrorQuoteKey, }); @@ -267,9 +268,13 @@ export const useSwapStore = create((set) => ({ ); } } + // The receive card renders destinationAmount directly: the previous + // quote's amount stays while a new quote is in flight (as designed) + // but must not sit next to a failure. set({ isLoadingPath: false, swapQuotes, + destinationAmount: "0", pathError: t(QUOTE_ERROR_KEYS[reason]), pathErrorQuoteKey, }); @@ -319,6 +324,7 @@ export const useSwapStore = create((set) => ({ set({ isLoadingPath: false, + destinationAmount: "0", pathError: displayError, pathErrorQuoteKey, });