diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index 3eab9bab0..402fc8f29 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"; @@ -51,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 @@ -67,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(), @@ -80,6 +84,7 @@ jest.mock("components/BottomSheet", () => { } return false; }, + customContent, }; // eslint-disable-next-line no-underscore-dangle globalThis.__mockSheetRefs.push(spy); @@ -106,9 +111,10 @@ type SwapStoreState = { sourceAmount: string; sourceAmountDisplay: string; destinationAmount: string; - pathResult: null | { destinationAmount: string }; + pathResult: null | { destinationAmount: string; quoteKey: string }; isLoadingPath: boolean; pathError: string | null; + pathErrorQuoteKey?: string | null; setSourceToken: jest.Mock; setDestinationToken: jest.Mock; setEntrySource: jest.Mock; @@ -145,6 +151,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 @@ -833,7 +840,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( @@ -875,7 +882,7 @@ describe("SwapAmountScreen", () => { setSwapStoreState({ sourceAmount: "1", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, }); const { getByTestId } = renderWithProviders( @@ -968,7 +975,7 @@ describe("SwapAmountScreen", () => { setSwapStoreState({ sourceAmount: "1", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, destinationToken: { id: "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", tokenCode: "FTT", @@ -1010,7 +1017,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", @@ -1076,7 +1083,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", @@ -1138,7 +1145,7 @@ describe("SwapAmountScreen", () => { setSwapStoreState({ sourceTokenId: "XLM", sourceAmount: "0.89", - pathResult: { destinationAmount: "2" }, + pathResult: { destinationAmount: "2", quoteKey: "quote-key" }, destinationToken: { id: "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", tokenCode: "FTT", @@ -1889,4 +1896,198 @@ 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(); + }); + }); + + 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({ ...failed(NO_ROUTE) }); + const { getByTestId } = renderScreen(); + + expect(getByTestId("swap-quote-unavailable")).toHaveTextContent( + "Quote unavailable", + ); + }); + + it("shows when the source is unavailable", () => { + setSwapStoreState({ ...failed(UNAVAILABLE) }); + const { getByTestId } = renderScreen(); + + expect(getByTestId("swap-quote-unavailable")).toBeTruthy(); + }); + + it("is hidden while quoting", () => { + setSwapStoreState({ ...failed(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({ + ...failed(NO_ROUTE), + sourceAmount: "0", + sourceAmountDisplay: "0", + }); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("swap-quote-unavailable")).toBeNull(); + }); + + it("is hidden when no destination is chosen", () => { + setSwapStoreState({ ...failed(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({ ...failed(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.", + ); + }); + 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__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts new file mode 100644 index 000000000..4d258e53d --- /dev/null +++ b/__tests__/components/screens/SwapScreen/helpers/selectSwapRoute.test.ts @@ -0,0 +1,120 @@ +import { + selectSwapRoute, + swapQuoteErrorReason, + 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(); + }); + + // 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({ + 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([]); + }); +}); + +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"); + }); + + 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__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts index fb0738f69..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, @@ -259,7 +280,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 new file mode 100644 index 000000000..86dd10cde --- /dev/null +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapCtaState.test.ts @@ -0,0 +1,62 @@ +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", + 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"); + }); +}); + +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/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts new file mode 100644 index 000000000..7e4b8f63b --- /dev/null +++ b/__tests__/components/screens/SwapScreen/hooks/useSwapForXlmReserve.test.ts @@ -0,0 +1,416 @@ +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 { SwapQuotes, 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 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(); + 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" }), + ); + expect(logger.error).toHaveBeenCalled(); + }); + + 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(); + // 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 () => { + mockFetchSwapQuotes.mockResolvedValue({ + routes: [], + unavailable: [{ source: "classic", reason: "no_route" }], + }); + const { result } = renderReserveHook(); + + await act(async () => { + await result.current.handleSwapForXlmFromSheet(); + }); + + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).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("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(() => + 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/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__/components/screens/SwapScreen/useSwapTransaction.test.ts b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts index 333ebdc4b..dd095d7f6 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,37 @@ 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(() => + 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 new file mode 100644 index 000000000..ee2aada5d --- /dev/null +++ b/__tests__/ducks/swap.findSwapPath.test.ts @@ -0,0 +1,479 @@ +import BigNumber from "bignumber.js"; +import { NETWORKS } from "config/constants"; +import { logger } from "config/logger"; +import { PricedBalance, SwapQuotes, SwapRoute } from "config/types"; +import { swapQuoteKey, 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 inputs = (sourceAmount = "10") => ({ + sourceBalance: xlmBalance, + destinationBalance: usdcBalance, + sourceAmount, + network: NETWORKS.PUBLIC, + publicKey: ACCOUNT, +}); + +const find = (sourceAmount = "10") => + useSwapStore.getState().findSwapPath(inputs(sourceAmount)); + +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", + path: [AQUA], + conversionRate: "0.2000000", + quoteKey: swapQuoteKey(inputs("10")), + }); + expect(state.destinationAmount).toBe("2.0000000"); + 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: [], + 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.pathErrorQuoteKey).toBe(swapQuoteKey(inputs("10"))); + 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], + 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", + ); + // 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])( + "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"); + expect(state.pathErrorQuoteKey).toBe(swapQuoteKey(inputs("10"))); + }, + ); + + 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 + .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); + }); + + it("reports a thrown outage to Sentry", async () => { + mockFetchSwapQuotes.mockRejectedValue(apiError(0)); + + await find(); + + 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) => { + 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); + }); +}); + +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/__tests__/services/backend.swapQuotes.test.ts b/__tests__/services/backend.swapQuotes.test.ts new file mode 100644 index 000000000..8e3939d60 --- /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.toMatchObject({ name: "SwapQuotesResponseError" }); + }); +}); diff --git a/src/components/screens/SwapScreen/helpers/index.ts b/src/components/screens/SwapScreen/helpers/index.ts index 7706466c6..5276fb0c2 100644 --- a/src/components/screens/SwapScreen/helpers/index.ts +++ b/src/components/screens/SwapScreen/helpers/index.ts @@ -22,3 +22,9 @@ export { getQuoteExpiredOperationCodes, 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 new file mode 100644 index 000000000..11b8ae862 --- /dev/null +++ b/src/components/screens/SwapScreen/helpers/selectSwapRoute.ts @@ -0,0 +1,53 @@ +import { SwapQuotes, SwapRoute } from "config/types"; +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. 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" && (route.paths[0]?.hops.length ?? 0) > 0, + ) ?? 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); + +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. 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 (error instanceof Error && error.name === "SwapQuotesResponseError") { + return "unavailable"; + } + if (!isApiError(error)) return null; + return error.status === 400 ? "noRoute" : "unavailable"; +}; 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/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 }; diff --git a/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts b/src/components/screens/SwapScreen/hooks/useSwapCtaState.ts index 0d510cb83..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" } @@ -42,6 +43,7 @@ export const useSwapCtaState = ({ pathResult, pathError, amountError, + currentQuoteKey, }: { sourceBalance: PricedBalance | undefined; destinationTokenDescriptor: DestinationTokenDescriptor | null; @@ -52,6 +54,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,11 +78,18 @@ 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" }; + } + + // 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" }; - // 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. + // No quote yet for these inputs: keep the user on the amount step. return { kind: "enter" }; }, [ sourceBalance, @@ -89,6 +100,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 32f6030d2..8aee2d584 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapForXlmReserve.ts @@ -1,10 +1,12 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { Asset, Horizon } from "@stellar/stellar-sdk"; import BigNumber from "bignumber.js"; +import { + missingSwapRouteReason, + 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"; @@ -13,12 +15,14 @@ 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 { sourceBalance: HeldBalanceItem | undefined; bestNonXlmClassicBalance: HeldBalanceItem | undefined; network: NETWORKS; + publicKey: string | undefined; subentryCount: number; swapFee: string; setSourceToken: (id: string, symbol: string) => void; @@ -45,6 +49,7 @@ export const useSwapForXlmReserve = ({ sourceBalance, bestNonXlmClassicBalance, network, + publicKey, subentryCount, swapFee, setSourceToken, @@ -52,18 +57,34 @@ 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), 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(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); const isCurrentSourceNonXlmClassic = !!sourceBalance && !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; - // 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 @@ -87,26 +108,50 @@ export const useSwapForXlmReserve = ({ : undefined; if (!sellTokenCode || !sellIssuer) return; + quoteRequestRef.current += 1; + const requestId = quoteRequestRef.current; + const quotedFor = quoteInputsRef.current; 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 && isCurrentSourceNonXlmClassic) { + const quotes = await fetchSwapQuotes({ + network, + account: publicKey, + mode: "receive", + fromAsset: `${sellTokenCode}:${sellIssuer}`, + toAsset: NATIVE_TOKEN_CODE, + amount: "0.5", + }); + receivedSourceAmount = selectSwapRoute(quotes)?.fromAmount ?? null; + if ( + !receivedSourceAmount && + missingSwapRouteReason(quotes) === "unavailable" + ) { + logger.error( + "useSwapForXlmReserve.handleSwapForXlmFromSheet", + "receive quote source unavailable", + quotes.unavailable, + ); + } + } } 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", - "strictReceivePaths failed", + "receive quote failed", error, ); } + if ( + !isMountedRef.current || + requestId !== quoteRequestRef.current || + quotedFor !== quoteInputsRef.current + ) { + return; + } setDestinationToken({ id: NATIVE_TOKEN_CODE, @@ -147,6 +192,7 @@ export const useSwapForXlmReserve = ({ sourceBalance, bestNonXlmClassicBalance, network, + publicKey, subentryCount, swapFee, setSourceToken, 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 524190bd6..d5ad22d1c 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -25,7 +25,11 @@ import { ActiveAccount } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useHistoryStore } from "ducks/history"; import { usePricesStore } from "ducks/prices"; -import { SwapPathResult, useSwapStore } from "ducks/swap"; +import { + computeDestMinWithSlippage, + SwapPathResult, + useSwapStore, +} from "ducks/swap"; import { useSwapSettingsStore } from "ducks/swapSettings"; import { SubmitResultCodes, @@ -74,6 +78,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< @@ -106,6 +112,7 @@ export const useSwapTransaction = ({ sourceBalance, destinationTokenInput, pathResult, + currentQuoteKey, account, network, navigation, @@ -140,9 +147,16 @@ 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(); + 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 @@ -170,7 +184,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, @@ -197,11 +216,13 @@ export const useSwapTransaction = ({ sourceBalance, destinationTokenInput, pathResult, + currentQuoteKey, buildSwapTransaction, account?.publicKey, sourceAmount, network, scanTransaction, + t, ]); const executeSwap = useCallback(async () => { @@ -582,7 +603,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 5e70cbf38..7b6ba8c23 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"; @@ -57,7 +58,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"; @@ -114,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(); @@ -125,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(); @@ -150,6 +156,7 @@ const SwapAmountScreen: React.FC = ({ pathResult, isLoadingPath, pathError, + pathErrorQuoteKey, setSourceToken, setDestinationToken, setEntrySource, @@ -250,11 +257,30 @@ 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, sourceAmount, - swapSlippage, network, publicKey: account?.publicKey, amountError, @@ -273,7 +299,7 @@ const SwapAmountScreen: React.FC = ({ sourceBalance, destinationTokenInput: destinationForPath, pathResult, - + currentQuoteKey, account, network, navigation, @@ -384,8 +410,23 @@ const SwapAmountScreen: React.FC = ({ pathResult, pathError, amountError, + 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 @@ -522,6 +563,7 @@ const SwapAmountScreen: React.FC = ({ sourceBalance, bestNonXlmClassicBalance, network, + publicKey: account?.publicKey, subentryCount: account?.subentryCount ?? 0, swapFee, setSourceToken, @@ -683,9 +725,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. @@ -914,6 +959,20 @@ const SwapAmountScreen: React.FC = ({ placeholderActive={!destinationTokenDescriptor} /> + {showQuoteUnavailable && ( + quoteUnavailableSheetRef.current?.present()} + hitSlop={8} + testID="swap-quote-unavailable" + > + + {t("swapScreen.quoteUnavailable.title")} + + + + )} + @@ -1081,6 +1140,19 @@ const SwapAmountScreen: React.FC = ({ /> } /> + quoteUnavailableSheetRef.current?.dismiss()} + closeTestID="quote-unavailable-close" + /> + } + /> Promise; @@ -83,13 +83,15 @@ const initialState = { sourceAmountDisplay: "0", destinationAmount: "0", pathResult: null, + swapQuotes: null as SwapQuotes | null, isLoadingPath: false, pathError: null, + pathErrorQuoteKey: null as string | null, isBuilding: false, buildError: null, }; -const computeDestMinWithSlippage = ( +export const computeDestMinWithSlippage = ( destinationAmount: string, slippage: number, ): string => { @@ -97,59 +99,59 @@ 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: { +const toSwapWireAsset = (balance: PricedBalance): string => { + const token = getTokenForPayment(balance); + return token.isNative() + ? NATIVE_TOKEN_CODE + : `${token.getCode()}:${token.getIssuer()}`; +}; + +interface SwapQuoteInputs { 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; - } + publicKey: string; +} - const bestPath = pathsResult.records[0]; +const sendQuoteRequest = (inputs: SwapQuoteInputs) => ({ + network: inputs.network, + account: inputs.publicKey, + mode: "send" as const, + fromAsset: toSwapWireAsset(inputs.sourceBalance), + toAsset: toSwapWireAsset(inputs.destinationBalance), + // The backend accepts only plain decimals; the store can hold "5." or "1e-7". + amount: new BigNumber(inputs.sourceAmount).toFixed(), +}); - const path: string[] = bestPath.path.map((token: HorizonPathToken) => { - if (isNativeAssetId(token.asset_type)) { - return "native"; - } - return `${token.asset_code}:${token.asset_issuer}`; - }); +const quoteKeyOf = (r: ReturnType): string => + [r.account, r.network, r.mode, r.fromAsset, r.toAsset, r.amount].join("|"); - const sourceAmountBN = new BigNumber(sourceAmount); - const destAmountBN = new BigNumber(bestPath.destination_amount); - const conversionRate = destAmountBN.dividedBy(sourceAmountBN).toFixed(7); - - return { - sourceAmount, - destinationAmount: bestPath.destination_amount, - path, - conversionRate, - }; - } catch (error) { - logger.error("SwapStore", "Failed to find classic swap path", error); - throw error; +/** + * 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; } }; +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; +// 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, @@ -194,15 +196,20 @@ export const useSwapStore = create((set) => ({ }, findSwapPath: async (params) => { - const { - sourceBalance, - destinationBalance, - sourceAmount, - slippage, - network, - } = params; + const { sourceBalance, destinationBalance, sourceAmount } = params; - set({ isLoadingPath: true, pathError: null, pathResult: null }); + quoteRequestId += 1; + const requestId = quoteRequestId; + const isStale = () => requestId !== quoteRequestId; + const pathErrorQuoteKey = swapQuoteKey(params); + + set({ + isLoadingPath: true, + pathError: null, + pathErrorQuoteKey: null, + pathResult: null, + swapQuotes: null, + }); try { const { forceSwapPathFailure } = useDebugStore.getState(); @@ -221,69 +228,127 @@ export const useSwapStore = create((set) => ({ isContractId(destinationBalance.id ?? "")); if (isSourceCustom || isDestCustom) { + outageReported = false; set({ isLoadingPath: false, + destinationAmount: "0", pathError: t("swapScreen.errors.customTokenSwapNotSupported"), + pathErrorQuoteKey, }); return; } - // TODO: Add Soroswap support for Testnet - const pathResult = await findClassicSwapPath({ - sourceBalance, - destinationBalance, - sourceAmount, - network, - }); - - if (!pathResult) { + 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") { + 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, + ); + } + } + // 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, - pathError: t("swapScreen.errors.noPathFound"), + swapQuotes, + destinationAmount: "0", + pathError: t(QUOTE_ERROR_KEYS[reason]), + pathErrorQuoteKey, }); return; } - const destinationAmountMin = computeDestMinWithSlippage( - pathResult.destinationAmount, - slippage, - ); - - const finalPathResult: SwapPathResult = { - ...pathResult, - destinationAmountMin, + const pathResult: SwapPathResult = { + sourceAmount, + destinationAmount: route.toAmount, + path: swapRouteIntermediateAssets(route), + conversionRate: new BigNumber(route.toAmount) + .dividedBy(new BigNumber(sourceAmount)) + .toFixed(7), + quoteKey: quoteKeyOf(request), }; + outageReported = false; set({ isLoadingPath: false, - pathResult: finalPathResult, - destinationAmount: pathResult.destinationAmount, + swapQuotes, + pathResult, + destinationAmount: route.toAmount, }); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - logger.error("SwapStore", "Failed to find swap path", error); + if (isStale()) return; - const displayError = __DEV__ + const errorMessage = + (error as { message?: string } | null)?.message ?? String(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"); + const displayError = reason ? t(QUOTE_ERROR_KEYS[reason]) : fallbackError; set({ isLoadingPath: false, + destinationAmount: "0", pathError: displayError, + pathErrorQuoteKey, }); } }, - clearPath: () => + clearPath: () => { + quoteRequestId += 1; + outageReported = false; set({ pathResult: null, + swapQuotes: null, destinationAmount: "0", pathError: null, - }), + pathErrorQuoteKey: null, + isLoadingPath: false, + }); + }, - resetSwap: () => set(initialState), + resetSwap: () => { + quoteRequestId += 1; + outageReported = false; + 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 630864cf3..e73a95521 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" }, @@ -752,6 +753,10 @@ "bodySuffix": " from your balance. You can get it back by removing the trustline after your {{tokenCode}} balance is zero.", "confirm": "Got it" }, + "quoteUnavailable": { + "title": "Quote unavailable", + "body": "We couldn't get a quote for this swap right now. Try a different amount or token pair, or try again later." + }, "trustlineBanner": "This will add a trustline to {{tokenCode}}", "xlmReserve": { "title": "You need XLM to create a trustline", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 8cb0bbc76..85331193b 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" }, @@ -718,6 +719,10 @@ "bodySuffix": " do seu saldo. Você poderá recuperá-los removendo a trustline quando o saldo de {{tokenCode}} for zero.", "confirm": "Entendi" }, + "quoteUnavailable": { + "title": "Cotação indisponível", + "body": "Não foi possível obter uma cotação para esta troca agora. Tente outro valor ou par de tokens, ou tente novamente mais tarde." + }, "trustlineBanner": "Isso adicionará uma trustline para {{tokenCode}}", "xlmReserve": { "title": "Você precisa de XLM para criar uma trustline", diff --git a/src/services/backend.ts b/src/services/backend.ts index 2e45e3f2f..78ebe6798 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, @@ -973,6 +976,49 @@ 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; +} + +/** 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 + * 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 SwapQuotesResponseError("Unexpected swap quotes response shape"); + } + + return data.data; +}; + /** * Fetches detailed information about a specific token contract * @async