From a4f1b8464a6db6e16b6ba6ccb8d4a2b537b6b486 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 16:43:08 -0500 Subject: [PATCH 01/32] feat(swap): route screen behind a See details sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the receive card, " XLM network fee · See details" opens a Details sheet (network fee in XLM and fiat, rate, route) once a quote is showing. C1-C3 had no details sheet; this adds it. The Route row reads routes[0] of the stored /swap/quotes response and is hidden when there are no quotes (custom networks, no quote yet) or the route has no paths. The Route pane (Figma 2983:153139 collapsed, 4241:50151 expanded, no per-hop amounts) shows the pair, "Split across N paths", one row per path with its share, hop venue icons (3 then +N) and "N pools" (= hops), and the distinct providers. Expanding a path lists its tokens with the venue between each pair. Back returns to Details, X closes. Venue icons come from /protocols (Discover's getDiscoverData), matched by name case-insensitively; unlisted venues such as stellar-dex get a generic Route icon and their raw name. Token codes and icons are read from the swap screen's own tokens, the wallet's icon maps, held balances and the asset lists; nothing is written to the icon cache. An unknown contract token is named by its shortened contract ID, as in the picker subtitle. Classic routes (no pool on any hop) use a STAND-IN design reviewers should check with design: the same pane, "1 path" instead of "Split across N paths", the share pill at 100% (any single path shows 100%, LI.FI included), and no pools label. Display only; nothing at signing reads it. The translation scanner also re-sorted three C3 keys that were out of order in the locale files. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.detailsSheet.test.tsx | 161 ++++++++++ .../components/swap/SwapAmount/index.tsx | 83 ++++- .../components/swap/SwapAmount/styles.scss | 29 ++ .../__tests__/SwapDetailsSheet.test.tsx | 179 +++++++++++ .../__tests__/routeViewModel.test.ts | 276 ++++++++++++++++ .../swap/SwapDetailsSheet/index.tsx | 299 ++++++++++++++++++ .../swap/SwapDetailsSheet/routeViewModel.ts | 189 +++++++++++ .../swap/SwapDetailsSheet/styles.scss | 228 +++++++++++++ .../swap/SwapDetailsSheet/useRouteSources.ts | 44 +++ .../src/popup/locales/en/translation.json | 20 +- .../src/popup/locales/pt/translation.json | 22 +- 11 files changed, 1523 insertions(+), 7 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx create mode 100644 extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx create mode 100644 extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts create mode 100644 extension/src/popup/components/swap/SwapDetailsSheet/index.tsx create mode 100644 extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts create mode 100644 extension/src/popup/components/swap/SwapDetailsSheet/styles.scss create mode 100644 extension/src/popup/components/swap/SwapDetailsSheet/useRouteSources.ts diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx new file mode 100644 index 0000000000..51a4c3b627 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { RequestState } from "constants/request"; +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper } from "popup/__testHelpers__"; +import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseSwapLiveQuote from "popup/components/swap/SwapAmount/hooks/useSwapLiveQuote"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as Internal from "@shared/api/internal"; +import * as TokenList from "@shared/api/helpers/token-list"; + +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "PUBLIC" }, + icons: {}, + userBalances: { + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, + }, + ], + }, + tokenPrices: { native: { currentPrice: "0.3" } }, +}; + +const swapQuotes = { + routes: [ + { + source: "lifi", + fromAmount: "5", + toAmount: "1.85", + fees: [], + paths: [ + { + share: "1", + hops: [{ venue: "Aquarius", from: "XLM", to: USDC, pool: "p1" }], + }, + ], + }, + ], + unavailable: [], +}; + +const renderSwapAmount = (transactionData: Record) => + render( + + + , + ); + +describe("SwapAmount details sheet", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest.spyOn(UseSwapLiveQuote, "useSwapLiveQuote").mockReturnValue({ + isLiveQuoteLoading: false, + isSwapUnavailable: false, + cancelLiveQuote: jest.fn(), + } as any); + jest.spyOn(Internal, "getDiscoverData").mockResolvedValue([]); + jest.spyOn(TokenList, "getCombinedAssetListData").mockResolvedValue([]); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("opens Details from See details, then Route, then back", async () => { + renderSwapAmount({}); + expect(screen.getByTestId("swap-details-link")).toHaveTextContent( + "{{fee}} XLM network fee", + ); + + await act(async () => { + fireEvent.click(screen.getByTestId("swap-see-details")); + }); + expect(screen.getByTestId("SwapDetails__fee")).toHaveTextContent( + "0.00002 XLM (<$0.01)", + ); + expect(screen.getByTestId("SwapDetails__rate")).toHaveTextContent( + "1 XLM ≈ 0.37 USDC", + ); + + fireEvent.click(screen.getByTestId("SwapDetails__route")); + expect(screen.getByTestId("SwapRoute")).toBeInTheDocument(); + expect(screen.getByTestId("SwapRoute__path-0")).toHaveTextContent("100%"); + + fireEvent.click(screen.getByTestId("SwapRoute__back")); + expect(screen.getByTestId("SwapDetails")).toBeInTheDocument(); + }); + + it("hides the Route row when no backend quotes are stored", async () => { + renderSwapAmount({ swapQuotes: null }); + await act(async () => { + fireEvent.click(screen.getByTestId("swap-see-details")); + }); + expect(screen.getByTestId("SwapDetails")).toBeInTheDocument(); + expect(screen.queryByTestId("SwapDetails__route")).toBeNull(); + }); + + it("shows no See details link without a quote", () => { + renderSwapAmount({ destinationAmount: "0", swapQuotes: null }); + expect(screen.queryByTestId("swap-details-link")).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 31b97487bb..e138c4284a 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -82,9 +82,15 @@ import { SlideupModal } from "popup/components/SlideupModal"; import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; -import { getHeldTokenDecimals, getSwapQuote } from "popup/helpers/swapQuote"; +import { + getHeldTokenDecimals, + getSwapQuote, + pickSwapRoute, + toBackendAsset, +} from "popup/helpers/swapQuote"; import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; +import { SwapDetailsSheet } from "popup/components/swap/SwapDetailsSheet"; import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote"; import { EditSlippage } from "./EditSlippage"; @@ -261,6 +267,7 @@ export const SwapAmount = ({ }); }, [isReviewingTx]); const [isXlmReserveOpen, setIsXlmReserveOpen] = useState(false); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); // Tracks focus on the sell input so the "Enter an amount" CTA can disable // itself while the input is focused. The extension has no virtual keyboard, // so once the input is focused the tap-to-focus affordance is redundant. @@ -590,6 +597,22 @@ export const SwapAmount = ({ review: t("Review swap"), }; + const hasQuote = + !!srcAsset && + !!dstAsset && + new BigNumber(amount || "0").gt(0) && + new BigNumber(destinationAmount || "0").gt(0); + const xlmPrice = data.tokenPrices?.native?.currentPrice; + const feeUsd = xlmPrice + ? new BigNumber(fee).multipliedBy(xlmPrice) + : undefined; + let feeFiat: string | undefined; + if (feeUsd?.isFinite()) { + feeFiat = feeUsd.lt(0.01) + ? "<$0.01" + : `$${formatAmount(roundUsdValue(feeUsd.toString()))}`; + } + const availableBalanceText = srcAsset ? `${displayTotal} ${srcAsset.code} ${t("available")}` : ""; @@ -875,6 +898,23 @@ export const SwapAmount = ({ }} /> + {hasQuote ? ( +
+ {t("{{fee}} XLM network fee", { fee })} + + +
+ ) : null}
)} + setIsDetailsOpen(false)} + isModalOpen={isDetailsOpen} + ariaLabel={t("Details")} + > + {isDetailsOpen && srcAsset && dstAsset ? ( + setIsDetailsOpen(false)} + /> + ) : ( + <> + )} + setIsXlmReserveOpen(false)} isModalOpen={isXlmReserveOpen} diff --git a/extension/src/popup/components/swap/SwapAmount/styles.scss b/extension/src/popup/components/swap/SwapAmount/styles.scss index 701cea2899..e3fd449ca9 100644 --- a/extension/src/popup/components/swap/SwapAmount/styles.scss +++ b/extension/src/popup/components/swap/SwapAmount/styles.scss @@ -307,3 +307,32 @@ margin-bottom: 2rem; } } + +.SwapAsset__details-link { + display: flex; + align-items: center; + justify-content: center; + gap: pxToRem(6px); + margin-top: pxToRem(12px); + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + line-height: pxToRem(18px); + + button { + display: inline-flex; + align-items: center; + gap: pxToRem(4px); + padding: 0; + border: none; + background: none; + color: var(--sds-clr-gray-12); + font: inherit; + font-weight: var(--sds-fw-medium); + cursor: pointer; + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + } + } +} diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx new file mode 100644 index 0000000000..b9d1e03a06 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx @@ -0,0 +1,179 @@ +import React from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { SwapRoute } from "@shared/api/types/backend-api"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { Wrapper } from "popup/__testHelpers__"; +import { SwapDetailsSheet } from "../index"; + +jest.mock("@shared/api/internal", () => ({ + getDiscoverData: jest.fn(), +})); +jest.mock("@shared/api/helpers/token-list", () => ({ + getCombinedAssetListData: jest.fn(), +})); + +const { getDiscoverData } = jest.requireMock< + typeof import("@shared/api/internal") +>("@shared/api/internal"); +const { getCombinedAssetListData } = jest.requireMock< + typeof import("@shared/api/helpers/token-list") +>("@shared/api/helpers/token-list"); + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC = `USDC:${USDC_ISSUER}`; + +const knownTokens = { + XLM: { code: "XLM", issuer: "", canonical: "native", icon: null }, + [USDC]: { code: "USDC", issuer: USDC_ISSUER, canonical: USDC, icon: null }, +}; + +const lifiRoute: SwapRoute = { + source: "lifi", + fromAmount: "100", + toAmount: "37", + fees: [], + paths: [ + { + share: "0.7", + hops: [{ venue: "Aquarius", from: "XLM", to: USDC, pool: "p1" }], + }, + { + share: "0.3", + hops: [{ venue: "SushiSwap", from: "XLM", to: USDC, pool: "p2" }], + }, + ], +}; + +const renderSheet = async ( + route: SwapRoute | null, + onClose: () => void = jest.fn(), +) => { + render( + + + , + ); + // Let the protocol and asset-list loads land. + await act(async () => {}); +}; + +describe("SwapDetailsSheet", () => { + beforeEach(() => { + (getDiscoverData as jest.Mock).mockResolvedValue([ + { + name: "Aquarius", + iconUrl: "https://icons.test/aquarius.png", + description: "", + websiteUrl: "", + tags: [], + isBlacklisted: false, + isTrending: false, + }, + ]); + (getCombinedAssetListData as jest.Mock).mockResolvedValue([]); + }); + afterEach(() => jest.clearAllMocks()); + + it("goes from Details to Route and back", async () => { + await renderSheet(lifiRoute); + + expect(screen.getByTestId("SwapDetails__fee")).toHaveTextContent( + "0.00001 XLM (<$0.01)", + ); + expect(screen.getByTestId("SwapDetails__rate")).toHaveTextContent( + "1 XLM ≈ 0.37 USDC", + ); + expect(screen.getByTestId("SwapDetails__route")).toHaveTextContent( + "{{count}} providers", + ); + expect( + screen + .getByTestId("SwapDetails__route") + .querySelectorAll(".SwapDetails__venue-icon"), + ).toHaveLength(2); + + fireEvent.click(screen.getByTestId("SwapDetails__route")); + + expect(screen.getByTestId("SwapRoute__path-count")).toHaveTextContent( + "Split across {{pathCount}} paths", + ); + expect(screen.getByTestId("SwapRoute__path-0")).toHaveTextContent("70%"); + expect(screen.getByTestId("SwapRoute__pools-0")).toHaveTextContent( + "{{count}} pools", + ); + await waitFor(() => + expect( + screen.getByTestId("SwapRoute__providers").querySelector("img"), + ).toHaveAttribute("src", "https://icons.test/aquarius.png"), + ); + expect( + screen + .getByTestId("SwapRoute__providers") + .querySelectorAll("[data-testid='SwapDetails__venue-icon-generic']"), + ).toHaveLength(1); + + fireEvent.click(screen.getByTestId("SwapRoute__path-1")); + expect(screen.getByTestId("SwapRoute__hops-1")).toHaveTextContent( + "XLMSushiSwapUSDC", + ); + + fireEvent.click(screen.getByTestId("SwapRoute__back")); + expect(screen.getByTestId("SwapDetails")).toBeInTheDocument(); + expect(screen.queryByTestId("SwapRoute")).toBeNull(); + }); + + it("closes from the Route pane", async () => { + const onClose = jest.fn(); + await renderSheet(lifiRoute, onClose); + fireEvent.click(screen.getByTestId("SwapDetails__route")); + fireEvent.click(screen.getByTestId("SwapRoute__close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("shows a classic route as one path with no pool count", async () => { + await renderSheet({ + source: "classic", + fromAmount: "100", + toAmount: "37", + fees: [], + paths: [ + { + share: "1", + hops: [{ venue: "stellar-dex", from: "XLM", to: USDC }], + }, + ], + }); + fireEvent.click(screen.getByTestId("SwapDetails__route")); + expect(screen.getByTestId("SwapRoute__path-count")).toHaveTextContent( + "1 path", + ); + expect(screen.getByTestId("SwapRoute__path-0")).toHaveTextContent("100%"); + expect(screen.queryByTestId("SwapRoute__pools-0")).toBeNull(); + }); + + it("hides the Route row without a route", async () => { + await renderSheet(null); + expect(screen.getByTestId("SwapDetails__rate")).toBeInTheDocument(); + expect(screen.queryByTestId("SwapDetails__route")).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts new file mode 100644 index 0000000000..1efcb98d02 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts @@ -0,0 +1,276 @@ +import { ProtocolEntry } from "@shared/api/types"; +import { AssetType } from "@shared/api/types/account-balance"; +import { SwapRoute } from "@shared/api/types/backend-api"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; + +import { + buildRouteView, + formatShare, + resolveRouteToken, + RouteTokenContext, +} from "../routeViewModel"; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC = `USDC:${USDC_ISSUER}`; +const EURC_ISSUER = "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"; +const EURC = `EURC:${EURC_ISSUER}`; +const KALE = "CB23WRDQWGSP6YPMY4UV5C4OW5CBTXKYN3XEATG7KJEZCXMJBYEHOUOV"; +const UNKNOWN_CONTRACT = + "CADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP5KR"; + +const protocol = (name: string, iconUrl: string): ProtocolEntry => ({ + name, + iconUrl, + description: "", + websiteUrl: "", + tags: [], + isBlacklisted: false, + isTrending: false, +}); + +const PROTOCOLS = [ + protocol("Aquarius", "https://icons.test/aquarius.png"), + protocol("SushiSwap", "https://icons.test/sushi.png"), + protocol("Soroswap", "https://icons.test/soroswap.png"), +]; + +const emptyContext: RouteTokenContext = { + known: {}, + icons: {}, + balances: [], + tokenLists: [], +}; + +const lifiRoute: SwapRoute = { + source: "lifi", + fromAmount: "1000", + toAmount: "370", + fees: [], + paths: [ + { + share: "0.32", + hops: [ + { venue: "Aquarius", from: "XLM", to: EURC, pool: "p1" }, + { venue: "SushiSwap", from: EURC, to: KALE, pool: "p2" }, + { venue: "Soroswap", from: KALE, to: USDC, pool: "p3" }, + ], + }, + { + share: "0.68", + hops: [ + { venue: "Soroswap", from: "XLM", to: EURC, pool: "p4" }, + { venue: "Aquarius", from: EURC, to: USDC, pool: "p5" }, + ], + }, + ], +}; + +const classicRoute: SwapRoute = { + source: "classic", + fromAmount: "1000", + toAmount: "371", + fees: [], + paths: [ + { + share: "1", + hops: [ + { venue: "stellar-dex", from: "XLM", to: EURC }, + { venue: "stellar-dex", from: EURC, to: USDC }, + ], + }, + ], +}; + +describe("formatShare", () => { + it.each([ + ["0.32", "32%"], + ["1", "100%"], + ["0.125", "13%"], + ["0.004", "<1%"], + ["0", "0%"], + ["junk", "0%"], + ])("formats %s as %s", (share, expected) => { + expect(formatShare(share)).toBe(expected); + }); +}); + +describe("buildRouteView", () => { + it("counts a path's pools as its hops", () => { + const view = buildRouteView({ + route: lifiRoute, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + expect(view.isClassic).toBe(false); + expect(view.paths.map((p) => p.poolCount)).toEqual([3, 2]); + expect(view.paths.map((p) => p.share)).toEqual(["32%", "68%"]); + }); + + it("lists each venue once across all paths, in first-seen order", () => { + const view = buildRouteView({ + route: lifiRoute, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + expect(view.providers.map((p) => p.name)).toEqual([ + "Aquarius", + "SushiSwap", + "Soroswap", + ]); + expect(view.providers[0].icon).toBe("https://icons.test/aquarius.png"); + }); + + it("orders a path's tokens with each venue between its two tokens", () => { + const view = buildRouteView({ + route: lifiRoute, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + const [first] = view.paths; + expect(first.tokens.map((t) => t.code)).toEqual([ + "XLM", + "EURC", + "CB23…OUOV", + "USDC", + ]); + expect(first.venues.map((v) => v.name)).toEqual([ + "Aquarius", + "SushiSwap", + "Soroswap", + ]); + expect(view.from.code).toBe("XLM"); + expect(view.to.code).toBe("USDC"); + }); + + it("drops the pool count on a classic route and shows its one path whole", () => { + const view = buildRouteView({ + route: classicRoute, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + expect(view.isClassic).toBe(true); + expect(view.paths).toHaveLength(1); + expect(view.paths[0].poolCount).toBeNull(); + expect(view.paths[0].share).toBe("100%"); + expect(view.providers).toEqual([{ name: "stellar-dex", icon: null }]); + }); + + it("gives a venue /protocols doesn't list no icon", () => { + const view = buildRouteView({ + route: classicRoute, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + expect(view.paths[0].venues[0]).toEqual({ + name: "stellar-dex", + icon: null, + }); + }); + + it("is null for a missing route or one with no paths", () => { + expect( + buildRouteView({ + route: null, + tokenContext: emptyContext, + protocols: [], + }), + ).toBeNull(); + expect( + buildRouteView({ + route: { ...lifiRoute, paths: [] }, + tokenContext: emptyContext, + protocols: [], + }), + ).toBeNull(); + }); +}); + +describe("resolveRouteToken", () => { + it("names an unknown contract token by its shortened contract ID", () => { + expect(resolveRouteToken(UNKNOWN_CONTRACT, emptyContext)).toEqual({ + code: "CADQ…P5KR", + issuer: UNKNOWN_CONTRACT, + canonical: `CADQ…P5KR:${UNKNOWN_CONTRACT}`, + icon: null, + }); + }); + + it("names a held contract token by its code", () => { + const balances = [ + { + token: { code: "KALE", issuer: { key: KALE } }, + contractId: KALE, + decimals: 7, + total: "1", + available: "1", + }, + ] as unknown as AssetType[]; + const token = resolveRouteToken(KALE, { ...emptyContext, balances }); + expect(token.code).toBe("KALE"); + expect(token.canonical).toBe(`KALE:${KALE}`); + }); + + it("names and pictures a listed contract token from the asset list", () => { + const tokenLists = [ + { + assets: [ + { + code: "KALE", + issuer: "", + contract: KALE, + icon: "https://icons.test/kale.png", + domain: "", + decimals: 7, + }, + ], + }, + ] as unknown as AssetListResponse[]; + expect(resolveRouteToken(KALE, { ...emptyContext, tokenLists })).toEqual({ + code: "KALE", + issuer: KALE, + canonical: `KALE:${KALE}`, + icon: "https://icons.test/kale.png", + }); + }); + + it("prefers the wallet's icon for a classic asset, then the asset list", () => { + const tokenLists = [ + { + assets: [ + { + code: "EURC", + issuer: EURC_ISSUER, + icon: "https://icons.test/eurc-list.png", + domain: "", + decimals: 7, + }, + ], + }, + ] as unknown as AssetListResponse[]; + expect(resolveRouteToken(EURC, { ...emptyContext, tokenLists }).icon).toBe( + "https://icons.test/eurc-list.png", + ); + expect( + resolveRouteToken(EURC, { + ...emptyContext, + tokenLists, + icons: { [EURC]: "https://icons.test/eurc-cache.png" }, + }).icon, + ).toBe("https://icons.test/eurc-cache.png"); + }); + + it("uses the screen's own token for the sold and bought assets", () => { + const known = { + XLM: { code: "XLM", issuer: "", canonical: "native", icon: null }, + [USDC]: { + code: "USDC", + issuer: USDC_ISSUER, + canonical: USDC, + icon: "https://icons.test/usdc.png", + }, + }; + expect(resolveRouteToken(USDC, { ...emptyContext, known }).icon).toBe( + "https://icons.test/usdc.png", + ); + }); +}); diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx new file mode 100644 index 0000000000..12ac56f695 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx @@ -0,0 +1,299 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@stellar/design-system"; + +import { AssetIcons } from "@shared/api/types"; +import { AssetType } from "@shared/api/types/account-balance"; +import { SwapRoute } from "@shared/api/types/backend-api"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { + SheetHeader, + SheetHeaderAction, + SheetHeaderLayout, +} from "popup/components/SheetHeader"; +import { SheetLayout } from "popup/components/SheetLayout"; +import { calculateSwapRate } from "popup/components/InternalTransaction/ReviewTransaction/helpers/calculateSwapRate"; +import { + buildRouteView, + MAX_PATH_VENUE_ICONS, + RouteToken, + RouteVenue, + RouteView, +} from "./routeViewModel"; +import { useRouteSources } from "./useRouteSources"; + +import "./styles.scss"; + +enum Pane { + Details = "details", + Route = "route", +} + +export const VenueIcon = ({ venue }: { venue: RouteVenue }) => + venue.icon ? ( + {venue.name} + ) : ( + + + + ); + +const TokenLabel = ({ token }: { token: RouteToken }) => ( + + + {token.code} + +); + +const VenueStack = ({ venues }: { venues: RouteVenue[] }) => { + const shown = venues.slice(0, MAX_PATH_VENUE_ICONS); + const extra = venues.length - shown.length; + return ( + + {shown.map((venue, i) => ( + + ))} + {extra > 0 ? ( + {`+${extra}`} + ) : null} + + ); +}; + +const RoutePane = ({ view }: { view: RouteView }) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(null); + const pathCount = view.paths.length; + + return ( +
+
+
+
+ + + +
+
+ {pathCount === 1 + ? t("1 path") + : t("Split across {{pathCount}} paths", { pathCount })} +
+
+ {view.paths.map((path, i) => { + const isOpen = expanded === i; + return ( +
+ + {isOpen ? ( +
+ {path.tokens.map((token, j) => ( + + + {path.venues[j] ? ( +
+ + {path.venues[j].name} +
+ ) : null} +
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ {t( + "Routes may be split across multiple hops to optimize rates and fees.", + )} +
+
+
{t("Providers")}
+
+ {view.providers.map((venue) => ( + + + {venue.name} + + ))} +
+
+
+ ); +}; + +interface SwapDetailsSheetProps { + route: SwapRoute | null | undefined; + fee: string; + /** "$0.22"; omitted when XLM has no price. */ + feeFiat?: string; + /** The sold and bought tokens as the screen shows them, keyed by backend asset ID. */ + knownTokens: Record; + srcCode: string; + dstCode: string; + sendAmount: string; + destinationAmount: string; + icons: AssetIcons; + balances: AssetType[]; + networkDetails: NetworkDetails; + onClose: () => void; +} + +export const SwapDetailsSheet = ({ + route, + fee, + feeFiat, + knownTokens, + srcCode, + dstCode, + sendAmount, + destinationAmount, + icons, + balances, + networkDetails, + onClose, +}: SwapDetailsSheetProps) => { + const { t } = useTranslation(); + const [pane, setPane] = useState(Pane.Details); + const { protocols, tokenLists, cachedIcons } = + useRouteSources(networkDetails); + const view = buildRouteView({ + route, + protocols, + tokenContext: { + known: knownTokens, + icons: { ...cachedIcons, ...icons }, + balances, + tokenLists, + }, + }); + + if (pane === Pane.Route && view) { + return ( + setPane(Pane.Details)} + aria-label={t("Back")} + data-testid="SwapRoute__back" + > + + + } + onClose={onClose} + closeTestId="SwapRoute__close" + /> + } + > + + + ); + } + + const rate = calculateSwapRate({ sendAmount, destinationAmount }); + + return ( + + } + > +
+
+
+ {t("Network fee")} + + {`${fee} XLM`} + {feeFiat ? ( + {` (${feeFiat})`} + ) : null} + +
+
+
+
+ {t("Rate")} + {`1 ${srcCode} ≈ ${rate} ${dstCode}`} +
+ {view ? ( + + ) : null} +
+
+ {t( + "Best route selected based on rates and fees. Quote may update as market conditions change.", + )} +
+
+
+ ); +}; diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts new file mode 100644 index 0000000000..8cd5516b62 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -0,0 +1,189 @@ +import BigNumber from "bignumber.js"; + +import { AssetIcons, ProtocolEntry } from "@shared/api/types"; +import { AssetType } from "@shared/api/types/account-balance"; +import { SwapRoute } from "@shared/api/types/backend-api"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; +import { findTokenListIcon } from "@shared/api/helpers/getIconFromTokenList"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { isNativeAssetPair } from "@shared/helpers/assetIdentity"; +import { truncateString } from "helpers/stellar"; +import { isContractId } from "popup/helpers/soroban"; + +export const MAX_PATH_VENUE_ICONS = 3; + +export interface RouteToken { + code: string; + /** "" for XLM, a G… issuer, or a C… contract ID. */ + issuer: string; + canonical: string; + icon: string | null; +} + +export interface RouteVenue { + name: string; + /** null when /protocols doesn't list the venue: render the generic icon. */ + icon: string | null; +} + +export interface RoutePathView { + /** "32%". */ + share: string; + /** One per hop, in order. */ + venues: RouteVenue[]; + /** hops + 1 tokens; venues[i] sits between tokens[i] and tokens[i + 1]. */ + tokens: RouteToken[]; + /** null on classic routes, which don't report pools. */ + poolCount: number | null; +} + +export interface RouteView { + from: RouteToken; + to: RouteToken; + paths: RoutePathView[]; + providers: RouteVenue[]; + /** Horizon routes: no pool on any hop. */ + isClassic: boolean; +} + +export interface RouteTokenContext { + /** Tokens the screen already shows, keyed by backend asset ID. */ + known: Record; + icons: AssetIcons; + balances: AssetType[]; + tokenLists: AssetListResponse[]; +} + +export const formatShare = (share: string) => { + const pct = new BigNumber(share).multipliedBy(100); + if (!pct.isFinite() || pct.lte(0)) { + return "0%"; + } + if (pct.lt(1)) { + return "<1%"; + } + return `${pct.integerValue(BigNumber.ROUND_HALF_UP).toFixed()}%`; +}; + +const findHeldContractCode = (balances: AssetType[], contractId: string) => { + for (const balance of balances) { + if ( + "contractId" in balance && + balance.contractId === contractId && + balance.token.code + ) { + return balance.token.code; + } + } + return undefined; +}; + +const findListedContract = ( + tokenLists: AssetListResponse[], + contractId: string, +) => { + for (const list of tokenLists) { + const record = list.assets?.find((a) => a.contract === contractId); + if (record) { + return record; + } + } + return undefined; +}; + +export const resolveRouteToken = ( + assetId: string, + { known, icons, balances, tokenLists }: RouteTokenContext, +): RouteToken => { + if (known[assetId]) { + return known[assetId]; + } + const [code, issuer = ""] = assetId.split(":"); + if (isNativeAssetPair(code, issuer)) { + return { code, issuer: "", canonical: "native", icon: null }; + } + if (isContractId(assetId)) { + const listed = findListedContract(tokenLists, assetId); + const contractCode = + findHeldContractCode(balances, assetId) || + listed?.code || + truncateString(assetId); + const canonical = getCanonicalFromAsset(contractCode, assetId); + return { + code: contractCode, + issuer: assetId, + canonical, + icon: icons[canonical] || listed?.icon || null, + }; + } + const canonical = getCanonicalFromAsset(code, issuer); + return { + code, + issuer, + canonical, + icon: + icons[canonical] || + findTokenListIcon({ issuerId: issuer, code, assetsListsData: tokenLists }) + .icon || + null, + }; +}; + +export const resolveVenue = ( + venue: string, + protocols: ProtocolEntry[], +): RouteVenue => { + const match = protocols.find( + (p) => p.name.toLowerCase() === venue.toLowerCase(), + ); + return match + ? { name: match.name, icon: match.iconUrl || null } + : { name: venue, icon: null }; +}; + +/** null when the route has nothing to show. */ +export const buildRouteView = ({ + route, + tokenContext, + protocols, +}: { + route: SwapRoute | null | undefined; + tokenContext: RouteTokenContext; + protocols: ProtocolEntry[]; +}): RouteView | null => { + const paths = (route?.paths ?? []).filter((p) => p.hops?.length); + if (!paths.length) { + return null; + } + const isClassic = paths.every((p) => p.hops.every((h) => !h.pool)); + const providers = new Map(); + + const pathViews = paths.map((path) => { + const venues = path.hops.map((hop) => { + const resolved = resolveVenue(hop.venue, protocols); + const key = resolved.name.toLowerCase(); + if (!providers.has(key)) { + providers.set(key, resolved); + } + return resolved; + }); + const tokens = [ + resolveRouteToken(path.hops[0].from, tokenContext), + ...path.hops.map((hop) => resolveRouteToken(hop.to, tokenContext)), + ]; + return { + share: formatShare(path.share), + venues, + tokens, + poolCount: isClassic ? null : path.hops.length, + }; + }); + + return { + from: pathViews[0].tokens[0], + to: pathViews[0].tokens[pathViews[0].tokens.length - 1], + paths: pathViews, + providers: [...providers.values()], + isClassic, + }; +}; diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss new file mode 100644 index 0000000000..089da56b86 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss @@ -0,0 +1,228 @@ +@use "../../../styles/utils.scss" as *; + +.SwapDetails { + display: flex; + flex-direction: column; + gap: pxToRem(12px); + color: var(--sds-clr-gray-12); + font-size: pxToRem(14px); + line-height: pxToRem(20px); + + &__card { + display: flex; + flex-direction: column; + padding: 0 pxToRem(16px); + border-radius: pxToRem(16px); + background-color: var(--sds-clr-gray-03); + + &--flush { + padding: 0; + } + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(8px); + min-height: pxToRem(48px); + + & + & { + border-top: 1px solid var(--sds-clr-gray-06); + } + + &--button { + width: 100%; + padding: 0; + border: none; + border-top: 1px solid var(--sds-clr-gray-06); + background: none; + color: inherit; + font: inherit; + cursor: pointer; + } + } + + &__label, + &__muted { + color: var(--sds-clr-gray-11); + } + + &__value { + display: flex; + align-items: center; + gap: pxToRem(6px); + font-weight: var(--sds-fw-medium); + text-align: right; + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + color: var(--sds-clr-gray-09); + } + } + + &__note { + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + line-height: pxToRem(18px); + } + + &__venue-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: pxToRem(20px); + height: pxToRem(20px); + border-radius: 50%; + object-fit: cover; + + &--generic { + background-color: var(--sds-clr-gray-06); + + svg { + width: pxToRem(12px); + height: pxToRem(12px); + color: var(--sds-clr-gray-11); + } + } + } + + &__venue-stack { + display: inline-flex; + align-items: center; + + .SwapDetails__venue-icon + .SwapDetails__venue-icon { + margin-left: pxToRem(-6px); + } + } + + &__venue-extra { + margin-left: pxToRem(6px); + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + } + + &__token { + display: inline-flex; + align-items: center; + gap: pxToRem(8px); + + .AccountAssets__asset--logo { + width: pxToRem(24px); + height: pxToRem(24px); + } + } + + &__token-code { + font-weight: var(--sds-fw-semi-bold); + } + + &__route-head { + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(4px); + padding: pxToRem(16px); + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + } + } + + &__route-pair { + display: flex; + align-items: center; + gap: pxToRem(8px); + font-size: pxToRem(16px); + } + + &__path { + border-top: 1px solid var(--sds-clr-gray-06); + } + + &__path-row { + display: flex; + align-items: center; + gap: pxToRem(12px); + width: 100%; + padding: pxToRem(12px) pxToRem(16px); + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + } + + &__share { + min-width: pxToRem(44px); + padding: pxToRem(2px) pxToRem(8px); + border-radius: pxToRem(100px); + background-color: var(--sds-clr-lilac-03); + color: var(--sds-clr-lilac-11); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + text-align: center; + } + + &__path-end { + display: flex; + align-items: center; + gap: pxToRem(6px); + margin-left: auto; + color: var(--sds-clr-gray-11); + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + } + } + + &__hops { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + margin: 0 pxToRem(16px) pxToRem(16px); + padding: pxToRem(12px) pxToRem(16px); + border-radius: pxToRem(12px); + background-color: var(--sds-clr-gray-01); + } + + &__hop-venue { + display: flex; + align-items: center; + gap: pxToRem(6px); + margin-left: pxToRem(11px); + padding-left: pxToRem(20px); + border-left: 1px dashed var(--sds-clr-gray-06); + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + } + + &__providers { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + padding-top: pxToRem(12px); + border-top: 1px solid var(--sds-clr-gray-06); + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: pxToRem(8px); + } + + &__chip { + display: inline-flex; + align-items: center; + gap: pxToRem(6px); + padding: pxToRem(4px) pxToRem(10px) pxToRem(4px) pxToRem(4px); + border-radius: pxToRem(100px); + background-color: var(--sds-clr-gray-03); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + } +} diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/useRouteSources.ts b/extension/src/popup/components/swap/SwapDetailsSheet/useRouteSources.ts new file mode 100644 index 0000000000..fbc69e8388 --- /dev/null +++ b/extension/src/popup/components/swap/SwapDetailsSheet/useRouteSources.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; + +import { getDiscoverData } from "@shared/api/internal"; +import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; +import { ProtocolEntry } from "@shared/api/types"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { settingsSelector } from "popup/ducks/settings"; +import { iconsSelector, tokensListsSelector } from "popup/ducks/cache"; + +/** + * Venue icons from /protocols and token icons from the asset lists, read + * only: a failure leaves the generic icons, and nothing is cached. + */ +export const useRouteSources = (networkDetails: NetworkDetails) => { + const { assetsLists } = useSelector(settingsSelector); + const cachedTokenLists = useSelector(tokensListsSelector); + const cachedIcons = useSelector(iconsSelector); + const [protocols, setProtocols] = useState([]); + const [tokenLists, setTokenLists] = + useState(cachedTokenLists); + + useEffect(() => { + let isActive = true; + getDiscoverData() + .then((data) => isActive && setProtocols(data)) + .catch(() => {}); + getCombinedAssetListData({ + networkDetails, + assetsLists, + cachedAssetLists: cachedTokenLists, + }) + .then((lists) => isActive && setTokenLists(lists)) + .catch(() => {}); + return () => { + isActive = false; + }; + // Once per open of the sheet. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { protocols, tokenLists, cachedIcons }; +}; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 19d1adc4e9..312a9daba5 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,15 +1,18 @@ { - "Couldn't read this token's details. Please try again.": "Couldn't read this token's details. Please try again.", - "No path found for swap.": "No path found for swap.", - "We had an issue retrieving your transaction details. Please try again.": "We had an issue retrieving your transaction details. Please try again.", "{{appName}} disconnected": "{{appName}} disconnected", "{{code}} hidden": "{{code}} hidden", "{{code}} unhidden": "{{code}} unhidden", + "{{count}} pools_one": "{{count}} pool", + "{{count}} pools_other": "{{count}} pools", + "{{count}} providers_one": "{{count}} provider", + "{{count}} providers_other": "{{count}} providers", "{{date}} at {{time}}": "{{date}} at {{time}}", "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "{{domain}} is not currently connected to Varial": "{{domain}} is not currently connected to Varial", + "{{fee}} XLM network fee": "{{fee}} XLM network fee", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", "0.5 XLM required": "0.5 XLM required", + "1 path": "1 path", "1:1 backed, unified liquidity": "1:1 backed, unified liquidity", "1D": "1D", "1H": "1H", @@ -108,6 +111,7 @@ "Balance details": "Balance details", "Balance ID": "Balance ID", "Before we start with migration, please read": "Before we start with migration, please read", + "Best route selected based on rates and fees. Quote may update as market conditions change.": "Best route selected based on rates and fees. Quote may update as market conditions change.", "Blockaid": "Blockaid", "Blockaid Response Override": "Blockaid Response Override", "Blockaid unfunded destination": "This is a new account and needs 1 XLM in order to get started. Any transaction to send non-XLM to an unfunded account will fail.", @@ -183,6 +187,7 @@ "Couldn’t hide {{code}}": "Couldn’t hide {{code}}", "Couldn’t hide collectible": "Couldn’t hide collectible", "Couldn’t open this dApp": "Couldn’t open this dApp", + "Couldn't read this token's details. Please try again.": "Couldn't read this token's details. Please try again.", "Couldn’t remove collectible": "Couldn’t remove collectible", "Couldn’t reset your wallet. Try again.": "Couldn’t reset your wallet. Try again.", "Couldn’t unhide {{code}}": "Couldn’t unhide {{code}}", @@ -212,6 +217,7 @@ "Destination Asset": "Destination Asset", "Destination Minimum": "Destination Minimum", "Destination Token": "Destination Token", + "Details": "Details", "Detect device": "Detect device", "Detecting": "Detecting", "Disabled": "Disabled", @@ -432,6 +438,7 @@ "Name": "Name", "Native": "Native", "Network": "Network", + "Network fee": "Network fee", "Network fees": "Network fees", "Network icon": "Network icon", "Network is in use": "Network is in use", @@ -450,6 +457,7 @@ "No hidden collectibles": "No hidden collectibles", "No hidden tokens": "No hidden tokens", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", + "No path found for swap.": "No path found for swap.", "No price data for this period": "No price data for this period", "No quote available": "No quote available", "No tokens match {{term}}": "No tokens match {{term}}", @@ -512,6 +520,7 @@ "Privacy Policy": "Privacy Policy", "Proceed with caution": "Proceed with caution", "Protocol details": "Protocol details", + "Providers": "Providers", "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", "Rate": "Rate", "Read before importing your key": "Read before importing your key", @@ -548,6 +557,8 @@ "Review swap": "Review swap", "Review transaction": "Review transaction", "Review transaction on device": "Review transaction on device", + "Route": "Route", + "Routes may be split across multiple hops to optimize rates and fees.": "Routes may be split across multiple hops to optimize rates and fees.", "Running integration tests ...": "Running integration tests ...", "Safe": "Safe", "Salt": "Salt", @@ -557,6 +568,7 @@ "Search issuer public key, classic assets, SAC assets, and TI assets": "Search issuer public key, classic assets, SAC assets, and TI assets", "Search token name or address": "Search token name or address", "Security": "Security", + "See details": "See details", "Select": "Select", "Select a hardware wallet you’d like to use with Varial.": "Select a hardware wallet you’d like to use with Varial.", "Select a token": "Select a token", @@ -610,6 +622,7 @@ "SOROBAN RPC URL": "SOROBAN RPC URL", "Source": "Source", "Source token not found": "Source token not found", + "Split across {{pathCount}} paths": "Split across {{pathCount}} paths", "Sponsored ID": "Sponsored ID", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.", "Starting Balance": "Starting Balance", @@ -833,6 +846,7 @@ "was swapped to": "was swapped to", "wasm": "wasm", "Wasm Hash": "Wasm Hash", + "We had an issue retrieving your transaction details. Please try again.": "We had an issue retrieving your transaction details. Please try again.", "We were unable to scan this site for security issues": "We were unable to scan this site for security issues", "We were unable to scan this token for security threats": "We were unable to scan this token for security threats", "WEBSITE CONNECTION IS NOT SECURE": "WEBSITE CONNECTION IS NOT SECURE", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 76c0b1313e..5938363d52 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,15 +1,20 @@ { - "Couldn't read this token's details. Please try again.": "Não foi possível ler os detalhes deste token. Tente novamente.", - "No path found for swap.": "Nenhum caminho encontrado para a troca.", - "We had an issue retrieving your transaction details. Please try again.": "Tivemos um problema ao obter os detalhes da sua transação. Tente novamente.", "{{appName}} disconnected": "{{appName}} desconectado", "{{code}} hidden": "{{code}} ocultado", "{{code}} unhidden": "{{code}} exibido", + "{{count}} pools_one": "{{count}} pool", + "{{count}} pools_many": "{{count}} pools", + "{{count}} pools_other": "{{count}} pools", + "{{count}} providers_one": "{{count}} provedor", + "{{count}} providers_many": "{{count}} provedores", + "{{count}} providers_other": "{{count}} provedores", "{{date}} at {{time}}": "{{date}} às {{time}}", "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "{{domain}} is not currently connected to Varial": "{{domain}} não está atualmente conectado ao Varial", + "{{fee}} XLM network fee": "Taxa de rede de {{fee}} XLM", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", "0.5 XLM required": "0,5 XLM necessários", + "1 path": "1 caminho", "1:1 backed, unified liquidity": "Lastro 1:1, liquidez unificada", "1D": "1D", "1H": "1H", @@ -108,6 +113,7 @@ "Balance details": "Detalhes do saldo", "Balance ID": "ID do Saldo", "Before we start with migration, please read": "Antes de começarmos com a migração, por favor leia", + "Best route selected based on rates and fees. Quote may update as market conditions change.": "Melhor rota selecionada com base nas cotações e taxas. A cotação pode mudar conforme as condições de mercado.", "Blockaid": "Blockaid", "Blockaid Response Override": "Substituição de Resposta do Blockaid", "Blockaid unfunded destination": "Esta é uma nova conta e precisa de 1 XLM para começar. Qualquer transação para enviar não-XLM para uma conta não financiada falhará.", @@ -183,6 +189,7 @@ "Couldn’t hide {{code}}": "Não foi possível ocultar {{code}}", "Couldn’t hide collectible": "Não foi possível ocultar o colecionável", "Couldn’t open this dApp": "Não foi possível abrir este dApp", + "Couldn't read this token's details. Please try again.": "Não foi possível ler os detalhes deste token. Tente novamente.", "Couldn’t remove collectible": "Couldn’t remove collectible", "Couldn’t reset your wallet. Try again.": "Não foi possível redefinir sua carteira. Tente novamente.", "Couldn’t unhide {{code}}": "Não foi possível exibir {{code}}", @@ -212,6 +219,7 @@ "Destination Asset": "Ativo de Destino", "Destination Minimum": "Mínimo de Destino", "Destination Token": "Token de destino", + "Details": "Detalhes", "Detect device": "Detectar dispositivo", "Detecting": "Detectando", "Disabled": "Desabilitado", @@ -432,6 +440,7 @@ "Name": "Nome", "Native": "Nativo", "Network": "Rede", + "Network fee": "Taxa de rede", "Network fees": "Taxas de rede", "Network icon": "Ícone de rede", "Network is in use": "Rede em uso", @@ -450,6 +459,7 @@ "No hidden collectibles": "Nenhum colecionável oculto", "No hidden tokens": "Nenhum token oculto", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", + "No path found for swap.": "Nenhum caminho encontrado para a troca.", "No price data for this period": "Sem dados de preço para este período", "No quote available": "Nenhuma cotação disponível", "No tokens match {{term}}": "Nenhum token corresponde a {{term}}", @@ -512,6 +522,7 @@ "Privacy Policy": "Política de Privacidade", "Proceed with caution": "Prossiga com cautela", "Protocol details": "Detalhes do protocolo", + "Providers": "Provedores", "Quote has expired, please try again to get a new quote": "A cotação expirou, tente novamente para obter uma nova cotação", "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", @@ -548,6 +559,8 @@ "Review swap": "Revisar troca", "Review transaction": "Revisar transação", "Review transaction on device": "Revisar transação no dispositivo", + "Route": "Rota", + "Routes may be split across multiple hops to optimize rates and fees.": "As rotas podem ser divididas em várias etapas para otimizar cotações e taxas.", "Running integration tests ...": "Executando testes de integração ...", "Safe": "Seguro", "Salt": "Salt", @@ -557,6 +570,7 @@ "Search issuer public key, classic assets, SAC assets, and TI assets": "Buscar chave pública do issuer, assets clássicos, assets SAC e assets TI", "Search token name or address": "Buscar nome do token ou endereço", "Security": "Segurança", + "See details": "Ver detalhes", "Select": "Selecionar", "Select a hardware wallet you’d like to use with Varial.": "Selecione uma carteira de hardware que você gostaria de usar com o Varial.", "Select a token": "Selecionar um token", @@ -610,6 +624,7 @@ "SOROBAN RPC URL": "URL RPC SOROBAN", "Source": "Origem", "Source token not found": "Token de origem não encontrado", + "Split across {{pathCount}} paths": "Dividida em {{pathCount}} caminhos", "Sponsored ID": "ID Patrocinado", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "Os certificados SSL fornecem uma conexão de rede criptografada e também fornecem prova de propriedade do domínio.", "Starting Balance": "Saldo Inicial", @@ -833,6 +848,7 @@ "was swapped to": "foi trocado para", "wasm": "wasm", "Wasm Hash": "Hash Wasm", + "We had an issue retrieving your transaction details. Please try again.": "Tivemos um problema ao obter os detalhes da sua transação. Tente novamente.", "We were unable to scan this site for security issues": "Não foi possível verificar este site quanto a problemas de segurança", "We were unable to scan this token for security threats": "Não foi possível verificar este token quanto a ameaças de segurança", "WEBSITE CONNECTION IS NOT SECURE": "CONEXÃO DO WEBSITE NÃO É SEGURA", From 700434822e441a1f9e52d5a0a301ad8e4cfb732f Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 17:00:49 -0500 Subject: [PATCH 02/32] fix(swap): See details only for the quote on screen, at any amount The sell amount is stored comma-formatted ("1,500"), so the link's BigNumber check failed from 1,000 up and the rate read 0. The link now shows only when the stored quote key matches the current account, network, tokens and amount (getSwapQuoteKey cleans the amount), and the rate gets cleanAmount(amount). This also hides the link and the sheet content while a re-quote is pending: after a token or amount change the stored route and output lag until the debounced quote lands, and the sheet showed the old route and a rate under the new symbol. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.detailsSheet.test.tsx | 40 +++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 21 ++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx index 51a4c3b627..3ff8a14f11 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -14,6 +14,7 @@ import * as UseSwapLiveQuote from "popup/components/swap/SwapAmount/hooks/useSwa import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as Internal from "@shared/api/internal"; import * as TokenList from "@shared/api/helpers/token-list"; +import { getSwapQuoteKey } from "popup/helpers/swapQuote"; const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; @@ -53,6 +54,18 @@ const swapQuotes = { unavailable: [], }; +const AQUA = "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + +const quoteKey = (amount: string, destAsset = USDC) => + getSwapQuoteKey({ + mode: "send", + amount, + sourceAsset: "native", + destAsset, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: "G123", + }); + const renderSwapAmount = (transactionData: Record) => render( ) => destinationTokenDetails: null, isToken: false, swapQuotes, + swapQuoteKey: quoteKey("5"), ...transactionData, }, }, @@ -154,6 +168,32 @@ describe("SwapAmount details sheet", () => { expect(screen.queryByTestId("SwapDetails__route")).toBeNull(); }); + it("reads a comma-formatted sell amount", async () => { + renderSwapAmount({ + amount: "1,500", + destinationAmount: "555", + swapQuoteKey: quoteKey("1500"), + }); + await act(async () => { + fireEvent.click(screen.getByTestId("swap-see-details")); + }); + expect(screen.getByTestId("SwapDetails__rate")).toHaveTextContent( + "1 XLM ≈ 0.37 USDC", + ); + }); + + it.each([ + ["the amount", { amount: "50" }], + ["the bought token", { swapQuoteKey: quoteKey("5", AQUA) }], + ["a cleared key", { swapQuoteKey: null }], + ])( + "shows no See details link while the quote is for other inputs (%s)", + (_, transactionData) => { + renderSwapAmount(transactionData); + expect(screen.queryByTestId("swap-details-link")).toBeNull(); + }, + ); + it("shows no See details link without a quote", () => { renderSwapAmount({ destinationAmount: "0", swapQuotes: null }); expect(screen.queryByTestId("swap-details-link")).toBeNull(); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index e138c4284a..239a2f3bee 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -85,6 +85,8 @@ import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; import { getHeldTokenDecimals, getSwapQuote, + getSwapQuoteKey, + isSameSwapQuoteKey, pickSwapRoute, toBackendAsset, } from "popup/helpers/swapQuote"; @@ -597,11 +599,22 @@ export const SwapAmount = ({ review: t("Review swap"), }; + // The stored quote lags the inputs until the debounced re-quote lands. const hasQuote = !!srcAsset && !!dstAsset && - new BigNumber(amount || "0").gt(0) && - new BigNumber(destinationAmount || "0").gt(0); + new BigNumber(destinationAmount || "0").gt(0) && + isSameSwapQuoteKey( + transactionData.swapQuoteKey, + getSwapQuoteKey({ + mode: "send", + amount: amount || "0", + sourceAsset: asset, + destAsset: destinationAsset, + networkDetails, + publicKey, + }), + ); const xlmPrice = data.tokenPrices?.native?.currentPrice; const feeUsd = xlmPrice ? new BigNumber(fee).multipliedBy(xlmPrice) @@ -1065,7 +1078,7 @@ export const SwapAmount = ({ isModalOpen={isDetailsOpen} ariaLabel={t("Details")} > - {isDetailsOpen && srcAsset && dstAsset ? ( + {isDetailsOpen && hasQuote && srcAsset && dstAsset ? ( Date: Mon, 9 Nov 2026 17:00:49 -0500 Subject: [PATCH 03/32] fix(swap): show pools per path, only where its hops name a pool A route mixing pooled and pool-less paths labelled every path "N pools". The label now follows the path's own hops. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/routeViewModel.test.ts | 18 ++++++++++++++++++ .../swap/SwapDetailsSheet/routeViewModel.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts index 1efcb98d02..fb79f58df3 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts @@ -155,6 +155,24 @@ describe("buildRouteView", () => { expect(view.providers).toEqual([{ name: "stellar-dex", icon: null }]); }); + it("shows pools only on paths whose hops carry one", () => { + const view = buildRouteView({ + route: { + ...lifiRoute, + paths: [ + lifiRoute.paths[0], + { + share: "0.68", + hops: [{ venue: "stellar-dex", from: "XLM", to: USDC }], + }, + ], + }, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + expect(view.paths.map((p) => p.poolCount)).toEqual([3, null]); + }); + it("gives a venue /protocols doesn't list no icon", () => { const view = buildRouteView({ route: classicRoute, diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts index 8cd5516b62..13fc004102 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -175,7 +175,7 @@ export const buildRouteView = ({ share: formatShare(path.share), venues, tokens, - poolCount: isClassic ? null : path.hops.length, + poolCount: path.hops.some((h) => h.pool) ? path.hops.length : null, }; }); From cb0d06578a87704b3f90795bebe93c583bc51b43 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 17:27:02 -0500 Subject: [PATCH 04/32] feat(swap): show a LI.FI route's resource fee in the swap fee /swap/quotes now sends resourceFee (XLM) on LI.FI routes; /swap/tx's fee is the inclusion fee plus that value. The footer fee, the See details line and the Details fee row (with its fiat) show inclusion + resourceFee of routes[0] when the stored quote is for the current inputs, otherwise the inclusion fee alone. Details adds Send's "Fees description soroban" note when the route has a resource fee. Max, spendable and the build are unchanged. Co-Authored-By: Claude Opus 5.5 --- @shared/api/types/backend-api.ts | 2 + .../SwapAmount.detailsSheet.test.tsx | 67 ++++++++++++++++++- .../components/swap/SwapAmount/index.tsx | 24 ++++--- .../swap/SwapDetailsSheet/index.tsx | 5 ++ 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/@shared/api/types/backend-api.ts b/@shared/api/types/backend-api.ts index 00eea815bf..a9a597dc92 100644 --- a/@shared/api/types/backend-api.ts +++ b/@shared/api/types/backend-api.ts @@ -179,6 +179,8 @@ export interface SwapRoute { toAmount: string; paths: SwapPath[]; fees: SwapFee[]; + /** LI.FI only: the simulated Soroban resource fee, in XLM. */ + resourceFee?: string; } export interface SwapUnavailable { diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx index 3ff8a14f11..36eb9d8171 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -16,6 +16,16 @@ import * as Internal from "@shared/api/internal"; import * as TokenList from "@shared/api/helpers/token-list"; import { getSwapQuoteKey } from "popup/helpers/swapQuote"; +// Interpolates, so the fee inside "{{fee}} XLM network fee" is checkable. +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + key.replace(/{{(\w+)}}/g, (_: string, name: string) => + String(opts?.[name] ?? `{{${name}}}`), + ), + }), +})); + const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; const swapData = { @@ -138,7 +148,7 @@ describe("SwapAmount details sheet", () => { it("opens Details from See details, then Route, then back", async () => { renderSwapAmount({}); expect(screen.getByTestId("swap-details-link")).toHaveTextContent( - "{{fee}} XLM network fee", + "0.00002 XLM network fee", ); await act(async () => { @@ -194,6 +204,61 @@ describe("SwapAmount details sheet", () => { }, ); + describe("fee display", () => { + const withRoute = (route: Record) => ({ + swapQuotes: { + ...swapQuotes, + routes: [{ ...swapQuotes.routes[0], ...route }], + }, + }); + + it("adds a LI.FI route's resource fee to the inclusion fee", async () => { + renderSwapAmount(withRoute({ resourceFee: "0.0858459" })); + expect(screen.getByTestId("swap-amount-fee-display")).toHaveTextContent( + "0.0858659 XLM", + ); + expect(screen.getByTestId("swap-details-link")).toHaveTextContent( + "0.0858659 XLM network fee", + ); + await act(async () => { + fireEvent.click(screen.getByTestId("swap-see-details")); + }); + expect(screen.getByTestId("SwapDetails__fee")).toHaveTextContent( + "0.0858659 XLM ($0.03)", + ); + expect(screen.getByTestId("SwapDetails")).toHaveTextContent( + "Fees description soroban", + ); + }); + + it("shows the inclusion fee alone on a classic route", async () => { + renderSwapAmount(withRoute({ source: "classic" })); + expect(screen.getByTestId("swap-amount-fee-display")).toHaveTextContent( + "0.00002 XLM", + ); + await act(async () => { + fireEvent.click(screen.getByTestId("swap-see-details")); + }); + expect(screen.getByTestId("SwapDetails__fee")).toHaveTextContent( + "0.00002 XLM (<$0.01)", + ); + expect(screen.getByTestId("SwapDetails")).not.toHaveTextContent( + "Fees description soroban", + ); + }); + + it("ignores the resource fee of a quote for other inputs", () => { + renderSwapAmount({ + ...withRoute({ resourceFee: "0.0858459" }), + amount: "50", + }); + expect(screen.queryByTestId("swap-details-link")).toBeNull(); + expect(screen.getByTestId("swap-amount-fee-display")).toHaveTextContent( + "0.00002 XLM", + ); + }); + }); + it("shows no See details link without a quote", () => { renderSwapAmount({ destinationAmount: "0", swapQuotes: null }); expect(screen.queryByTestId("swap-details-link")).toBeNull(); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 239a2f3bee..d5d2bedc3d 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -615,9 +615,17 @@ export const SwapAmount = ({ publicKey, }), ); + const quotedRoute = + hasQuote && transactionData.swapQuotes + ? pickSwapRoute(transactionData.swapQuotes) + : undefined; + const resourceFee = quotedRoute?.resourceFee; + const displayFee = resourceFee + ? new BigNumber(fee).plus(resourceFee).toFixed() + : fee; const xlmPrice = data.tokenPrices?.native?.currentPrice; const feeUsd = xlmPrice - ? new BigNumber(fee).multipliedBy(xlmPrice) + ? new BigNumber(displayFee).multipliedBy(xlmPrice) : undefined; let feeFiat: string | undefined; if (feeUsd?.isFinite()) { @@ -649,7 +657,7 @@ export const SwapAmount = ({ {/* The network fee is always denominated in XLM, regardless of whether the amount is being entered in crypto or fiat. */} - {`${fee} XLM`} + {`${displayFee} XLM`}
+ {route?.resourceFee ? ( +
+ {t("Fees description soroban")} +
+ ) : null}
{t("Rate")} From 03db48166548202c827f07c1b25239cc4eee1ea9 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 17:33:14 -0500 Subject: [PATCH 05/32] fix(swap): hold back a LI.FI route's resource fee in Max and the XLM fee check Max and the percent buttons for an XLM source, the insufficient-balance check, and the non-XLM "Not enough XLM for network fees" check held back only the inclusion fee, so Max on a LI.FI route left the resource fee unfunded. getSwapDerivedData now gets the displayed total (inclusion + routes[0].resourceFee for the quote matching the current inputs, else the inclusion fee), the same value the fee display uses. The error checks are included because they don't feed quoting: useSwapLiveQuote quotes whenever the cleaned amount is above 0, its inputs are amount, assets, network, account, the review freeze and the swap data, and isAmountTooHigh / insufficientXlmForFees only set the CTA label and the amount card's error styling; nothing dispatches or cancels a quote on them. So no error -> no quote -> no error loop. While a re-quote is pending the checks fall back to the inclusion fee. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.detailsSheet.test.tsx | 84 ++++++++++++++++++- .../components/swap/SwapAmount/index.tsx | 52 ++++++------ 2 files changed, 110 insertions(+), 26 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx index 36eb9d8171..b3ee58e0f1 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -5,7 +5,7 @@ import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { AppDataType } from "helpers/hooks/useGetAppData"; -import { Wrapper } from "popup/__testHelpers__"; +import { Wrapper, getTestStore } from "popup/__testHelpers__"; import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; import { SwapAmount } from "popup/components/swap/SwapAmount"; import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; @@ -259,6 +259,88 @@ describe("SwapAmount details sheet", () => { }); }); + describe("fees held back before review", () => { + const xlm = (total: string) => ({ + token: { type: "native", code: "XLM" }, + total: new BigNumber(total), + available: new BigNumber(total), + minimumBalance: new BigNumber("1"), + blockaidData: {}, + }); + const usdc = { + token: { + code: "USDC", + issuer: { key: USDC.split(":")[1] }, + }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, + }; + const withBalances = (balances: unknown[]) => + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { ...swapData, userBalances: { balances } }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + const quotes = (route: Record) => ({ + ...swapQuotes, + routes: [{ ...swapQuotes.routes[0], ...route }], + }); + const clickMax = () => + fireEvent.click(screen.getByTestId("SendAmountSetMax")); + const savedAmount = () => + getTestStore()!.getState().transactionSubmission.transactionData.amount; + + // 100 XLM - 1 minimum balance - 0.5 new-trustline reserve - fees. + it("Max on an XLM source leaves a LI.FI route's resource fee", () => { + withBalances([xlm("100")]); + renderSwapAmount({ swapQuotes: quotes({ resourceFee: "0.0858459" }) }); + clickMax(); + expect(savedAmount()).toBe("98.4141341"); + }); + + it("Max on an XLM source holds back the inclusion fee alone on a classic route", () => { + withBalances([xlm("100")]); + renderSwapAmount({ swapQuotes: quotes({ source: "classic" }) }); + clickMax(); + expect(savedAmount()).toBe("98.49998"); + }); + + const nonXlmSource = (route: Record) => ({ + asset: USDC, + destinationAsset: "native", + swapQuotes: quotes(route), + swapQuoteKey: getSwapQuoteKey({ + mode: "send", + amount: "5", + sourceAsset: USDC, + destAsset: "native", + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: "G123", + }), + }); + + // 0.0001 XLM spendable covers the 0.00001 inclusion fee, not the total. + it("flags a non-XLM source short of XLM for the resource fee", () => { + withBalances([xlm("1.0001"), usdc]); + renderSwapAmount(nonXlmSource({ resourceFee: "0.0858459" })); + expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent( + "Not enough XLM for network fees", + ); + }); + + it("lets a non-XLM source through on a classic route", () => { + withBalances([xlm("1.0001"), usdc]); + renderSwapAmount(nonXlmSource({ source: "classic" })); + expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent( + "Review swap", + ); + }); + }); + it("shows no See details link without a quote", () => { renderSwapAmount({ destinationAmount: "0", swapQuotes: null }); expect(screen.queryByTestId("swap-details-link")).toBeNull(); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index d5d2bedc3d..c59cf8ea36 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -463,6 +463,31 @@ export const SwapAmount = ({ state: swapAmountData.state, }); + // The stored quote lags the inputs until the debounced re-quote lands. + const hasQuote = + !!srcAsset && + !!dstAsset && + new BigNumber(destinationAmount || "0").gt(0) && + isSameSwapQuoteKey( + transactionData.swapQuoteKey, + getSwapQuoteKey({ + mode: "send", + amount: amount || "0", + sourceAsset: asset, + destAsset: destinationAsset, + networkDetails, + publicKey, + }), + ); + const quotedRoute = + hasQuote && transactionData.swapQuotes + ? pickSwapRoute(transactionData.swapQuotes) + : undefined; + const resourceFee = quotedRoute?.resourceFee; + const displayFee = resourceFee + ? new BigNumber(fee).plus(resourceFee).toFixed() + : fee; + // All balance/price/fee/security/CTA derivation. Plain function (not a hook): // it runs below the early returns, where a hook would violate rules-of-hooks. const { @@ -492,7 +517,8 @@ export const SwapAmount = ({ destinationTokenDetails: transactionData.destinationTokenDetails, amount: formik.values.amount, amountUsd: formik.values.amountUsd, - fee, + // Max and the XLM fee check hold back what the swap will pay. + fee: displayFee, blockaidOverrideState, networkDetails, inputType, @@ -599,30 +625,6 @@ export const SwapAmount = ({ review: t("Review swap"), }; - // The stored quote lags the inputs until the debounced re-quote lands. - const hasQuote = - !!srcAsset && - !!dstAsset && - new BigNumber(destinationAmount || "0").gt(0) && - isSameSwapQuoteKey( - transactionData.swapQuoteKey, - getSwapQuoteKey({ - mode: "send", - amount: amount || "0", - sourceAsset: asset, - destAsset: destinationAsset, - networkDetails, - publicKey, - }), - ); - const quotedRoute = - hasQuote && transactionData.swapQuotes - ? pickSwapRoute(transactionData.swapQuotes) - : undefined; - const resourceFee = quotedRoute?.resourceFee; - const displayFee = resourceFee - ? new BigNumber(fee).plus(resourceFee).toFixed() - : fee; const xlmPrice = data.tokenPrices?.native?.currentPrice; const feeUsd = xlmPrice ? new BigNumber(displayFee).multipliedBy(xlmPrice) From f3acbeeac11da46009e8a43f3f7e8f6397574884 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 16:46:37 -0500 Subject: [PATCH 06/32] fix(swap): keep route sheet token icons round The base logo rule sets min-width: 2rem, so shrinking only width and height to 24px left a 32x24 box and the image rendered as an ellipse. Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/components/swap/SwapDetailsSheet/styles.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss index 089da56b86..8f23224657 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss +++ b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss @@ -112,6 +112,7 @@ .AccountAssets__asset--logo { width: pxToRem(24px); height: pxToRem(24px); + min-width: pxToRem(24px); } } From c7ffbbab4917fdbbe63c6692e21afd6c838d0af0 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 17:02:32 -0500 Subject: [PATCH 07/32] Show stellar-dex route venue as Stellar DEX with the Stellar logo Classic routes report venue stellar-dex, which /protocols does not list, so the Route sheet rendered the raw name with the generic icon. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/routeViewModel.test.ts | 33 +++++++++++++++++-- .../swap/SwapDetailsSheet/routeViewModel.ts | 5 +++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts index fb79f58df3..123e305f34 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts @@ -3,6 +3,8 @@ import { AssetType } from "@shared/api/types/account-balance"; import { SwapRoute } from "@shared/api/types/backend-api"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; +import StellarLogo from "popup/assets/stellar-logo.png"; + import { buildRouteView, formatShare, @@ -152,7 +154,9 @@ describe("buildRouteView", () => { expect(view.paths).toHaveLength(1); expect(view.paths[0].poolCount).toBeNull(); expect(view.paths[0].share).toBe("100%"); - expect(view.providers).toEqual([{ name: "stellar-dex", icon: null }]); + expect(view.providers).toEqual([ + { name: "Stellar DEX", icon: StellarLogo }, + ]); }); it("shows pools only on paths whose hops carry one", () => { @@ -175,16 +179,39 @@ describe("buildRouteView", () => { it("gives a venue /protocols doesn't list no icon", () => { const view = buildRouteView({ - route: classicRoute, + route: { + ...classicRoute, + paths: [ + { + share: "1", + hops: [{ venue: "Mystery AMM", from: "XLM", to: USDC }], + }, + ], + }, tokenContext: emptyContext, protocols: PROTOCOLS, })!; expect(view.paths[0].venues[0]).toEqual({ - name: "stellar-dex", + name: "Mystery AMM", icon: null, }); }); + it("names stellar-dex Stellar DEX with the Stellar logo, once in Providers", () => { + const view = buildRouteView({ + route: classicRoute, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + expect(view.paths[0].venues).toEqual([ + { name: "Stellar DEX", icon: StellarLogo }, + { name: "Stellar DEX", icon: StellarLogo }, + ]); + expect(view.providers).toEqual([ + { name: "Stellar DEX", icon: StellarLogo }, + ]); + }); + it("is null for a missing route or one with no paths", () => { expect( buildRouteView({ diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts index 13fc004102..42e028bf74 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -9,6 +9,7 @@ import { getCanonicalFromAsset } from "@shared/helpers/stellar"; import { isNativeAssetPair } from "@shared/helpers/assetIdentity"; import { truncateString } from "helpers/stellar"; import { isContractId } from "popup/helpers/soroban"; +import StellarLogo from "popup/assets/stellar-logo.png"; export const MAX_PATH_VENUE_ICONS = 3; @@ -133,6 +134,10 @@ export const resolveVenue = ( venue: string, protocols: ProtocolEntry[], ): RouteVenue => { + // Not in /protocols: it would show up in Discover. + if (venue === "stellar-dex") { + return { name: "Stellar DEX", icon: StellarLogo }; + } const match = protocols.find( (p) => p.name.toLowerCase() === venue.toLowerCase(), ); From 08aa94c11ead5b58080f7c881e7b29459687191d Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 17:35:15 -0500 Subject: [PATCH 08/32] fix(swap): match route sheet spacing, venue icons and hop connector to Figma Figma 3405-175470: token icons 32px with a 4px gap to the code (the shared logo rule's 16px margin-right made the gap 24px); path-row venue icons 32px, 4px apart, no overlap; 16px between share badge and icons; hop connector is a solid line with a 5px dot, venue 24px icon with a 4px gap. Co-Authored-By: Claude Opus 5.5 --- .../swap/SwapDetailsSheet/styles.scss | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss index 8f23224657..25835a2632 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss +++ b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss @@ -73,8 +73,8 @@ align-items: center; justify-content: center; flex-shrink: 0; - width: pxToRem(20px); - height: pxToRem(20px); + width: pxToRem(24px); + height: pxToRem(24px); border-radius: 50%; object-fit: cover; @@ -92,14 +92,15 @@ &__venue-stack { display: inline-flex; align-items: center; + gap: pxToRem(4px); - .SwapDetails__venue-icon + .SwapDetails__venue-icon { - margin-left: pxToRem(-6px); + .SwapDetails__venue-icon { + width: pxToRem(32px); + height: pxToRem(32px); } } &__venue-extra { - margin-left: pxToRem(6px); color: var(--sds-clr-gray-11); font-size: pxToRem(12px); } @@ -107,12 +108,10 @@ &__token { display: inline-flex; align-items: center; - gap: pxToRem(8px); + gap: pxToRem(4px); .AccountAssets__asset--logo { - width: pxToRem(24px); - height: pxToRem(24px); - min-width: pxToRem(24px); + margin-right: 0; } } @@ -147,7 +146,7 @@ &__path-row { display: flex; align-items: center; - gap: pxToRem(12px); + gap: pxToRem(16px); width: 100%; padding: pxToRem(12px) pxToRem(16px); border: none; @@ -192,12 +191,26 @@ } &__hop-venue { + position: relative; display: flex; align-items: center; - gap: pxToRem(6px); - margin-left: pxToRem(11px); - padding-left: pxToRem(20px); - border-left: 1px dashed var(--sds-clr-gray-06); + gap: pxToRem(4px); + min-height: pxToRem(32px); + margin-left: pxToRem(14px); + padding-left: pxToRem(6px); + border-left: 1px solid var(--sds-clr-gray-06); + + &::before { + content: ""; + position: absolute; + top: 50%; + left: pxToRem(-3px); + width: pxToRem(5px); + height: pxToRem(5px); + border-radius: 50%; + background-color: var(--sds-clr-gray-06); + transform: translateY(-50%); + } color: var(--sds-clr-gray-11); font-size: pxToRem(12px); } From 9da92c4cb4d4b8dde3c4489ee8ea67bafdf1e504 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 18:43:47 -0500 Subject: [PATCH 09/32] fix(swap): route share badge uses the design's blue badge colours Figma 3405-175551 uses Blue/04 background, Blue/06 border, Blue/09 14px semibold text; navy-04/06/09 are the nearest SDS tokens. Co-Authored-By: Claude Opus 5.5 --- .../components/swap/SwapDetailsSheet/styles.scss | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss index 25835a2632..86a975e8fa 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss +++ b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss @@ -157,13 +157,15 @@ } &__share { - min-width: pxToRem(44px); - padding: pxToRem(2px) pxToRem(8px); + min-width: pxToRem(48px); + padding: pxToRem(1px) pxToRem(8px); + border: 1px solid var(--sds-clr-navy-06); border-radius: pxToRem(100px); - background-color: var(--sds-clr-lilac-03); - color: var(--sds-clr-lilac-11); - font-size: pxToRem(12px); - font-weight: var(--sds-fw-medium); + background-color: var(--sds-clr-navy-04); + color: var(--sds-clr-navy-09); + font-size: pxToRem(14px); + line-height: pxToRem(20px); + font-weight: var(--sds-fw-semi-bold); text-align: center; } From 07e9f81a0cd0bffa89d38336a828e40b0ce75790 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 10 Nov 2026 18:53:17 -0500 Subject: [PATCH 10/32] fix(swap): Details route row overlaps 16px venue icons again The route-sheet spacing change also enlarged the Details row stack. Figma 2863-148281 overlaps 16px icons by 4px there. Co-Authored-By: Claude Opus 5.5 --- .../components/swap/SwapDetailsSheet/styles.scss | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss index 86a975e8fa..fbcb4647cf 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss +++ b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss @@ -100,6 +100,21 @@ } } + // The Details sheet's Route row overlaps small icons; the Route sheet spaces + // large ones (Figma 2863-148281 vs 3405-175470). + &__row--button &__venue-stack { + gap: 0; + + .SwapDetails__venue-icon { + width: pxToRem(16px); + height: pxToRem(16px); + } + + .SwapDetails__venue-icon + .SwapDetails__venue-icon { + margin-left: pxToRem(-4px); + } + } + &__venue-extra { color: var(--sds-clr-gray-11); font-size: pxToRem(12px); From 22062db583e66ba98cf746974197065d3b085923 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 12:39:32 -0500 Subject: [PATCH 11/32] fix(swap): space the "+N" venue label from the Details row icons 07e9f81a0 set the Details row venue stack to gap 0 for overlapping icons; 08aa94c11 had already dropped the label's own 6px margin, so "+N" touched the last icon. Restore the 6px in that row only. Co-Authored-By: Claude Opus 5.5 --- .../src/popup/components/swap/SwapDetailsSheet/styles.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss index fbcb4647cf..7d592dddb4 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss +++ b/extension/src/popup/components/swap/SwapDetailsSheet/styles.scss @@ -113,6 +113,10 @@ .SwapDetails__venue-icon + .SwapDetails__venue-icon { margin-left: pxToRem(-4px); } + + .SwapDetails__venue-extra { + margin-left: pxToRem(6px); + } } &__venue-extra { From fca3877cf1543e1205d75cbc672610e0dfa7d15a Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 13:46:48 -0500 Subject: [PATCH 12/32] fix(swap): keep a route hop's code whole when it contains a colon resolveRouteToken split hop asset IDs on the first colon, so a hop "MY:TOKEN:C..." rendered as "MY" with a malformed issuer. Use splitCanonical (last colon), as C1 0c9b324d7 did for the quote request. Also drop a useSwapLiveQuote mock field C1 removed. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx | 1 - .../SwapDetailsSheet/__tests__/routeViewModel.test.ts | 8 ++++++++ .../components/swap/SwapDetailsSheet/routeViewModel.ts | 7 +++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx index 0804b754de..5ece04d347 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -135,7 +135,6 @@ describe("SwapAmount details sheet", () => { fetchData: jest.fn().mockResolvedValue(undefined), } as any); jest.spyOn(UseSwapLiveQuote, "useSwapLiveQuote").mockReturnValue({ - isSwapUnavailable: false, cancelLiveQuote: jest.fn(), } as any); jest.spyOn(Internal, "getDiscoverData").mockResolvedValue([]); diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts index 123e305f34..4bc9b0ef2b 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts @@ -231,6 +231,14 @@ describe("buildRouteView", () => { }); describe("resolveRouteToken", () => { + it("keeps a code that contains a colon whole", () => { + expect(resolveRouteToken(`MY:TOKEN:${KALE}`, emptyContext)).toMatchObject({ + code: "MY:TOKEN", + issuer: KALE, + canonical: `MY:TOKEN:${KALE}`, + }); + }); + it("names an unknown contract token by its shortened contract ID", () => { expect(resolveRouteToken(UNKNOWN_CONTRACT, emptyContext)).toEqual({ code: "CADQ…P5KR", diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts index 42e028bf74..bacaabba8c 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -5,7 +5,10 @@ import { AssetType } from "@shared/api/types/account-balance"; import { SwapRoute } from "@shared/api/types/backend-api"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; import { findTokenListIcon } from "@shared/api/helpers/getIconFromTokenList"; -import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { + getCanonicalFromAsset, + splitCanonical, +} from "@shared/helpers/stellar"; import { isNativeAssetPair } from "@shared/helpers/assetIdentity"; import { truncateString } from "helpers/stellar"; import { isContractId } from "popup/helpers/soroban"; @@ -99,7 +102,7 @@ export const resolveRouteToken = ( if (known[assetId]) { return known[assetId]; } - const [code, issuer = ""] = assetId.split(":"); + const { code, issuer } = splitCanonical(assetId); if (isNativeAssetPair(code, issuer)) { return { code, issuer: "", canonical: "native", icon: null }; } From 4a1d6df8011894ffa10d646e9d3964754a448f8e Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 20:38:32 -0500 Subject: [PATCH 13/32] test(swap): C3's identity test passes C4's getSwapDerivedData params The merge brought in a C3 test written against C3's helper signature (publicKey, swapQuoteKey); C4 takes isQuoteForInputs instead. Co-Authored-By: Claude Opus 5.5 --- .../swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx index f4cc87b4d6..529566bfe0 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx @@ -222,7 +222,7 @@ describe("getSwapDerivedData destination identity", () => { fee: "0.00001", networkDetails: MAINNET_NETWORK_DETAILS, inputType: "crypto", - swapQuoteKey: null, + isQuoteForInputs: false, destRequiresTrustline: false, }); From 64871510cc757b98257cc3cc19d9e5acca810fa1 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:01:59 -0500 Subject: [PATCH 14/32] fix(swap): count only the hops that carry a pool A path's pool count was its hop count whenever any hop had a pool, so an AMM hop followed by an orderbook hop read "2 pools". It now counts the hops with a pool and stays null when none do. "1 pool" is already translated in en and pt. Copilot review on #3076 (same line on mobile #1051). Co-Authored-By: Claude Opus 5.5 --- .../__tests__/routeViewModel.test.ts | 20 +++++++++++++++++++ .../swap/SwapDetailsSheet/routeViewModel.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts index 4bc9b0ef2b..73ae389c72 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts @@ -177,6 +177,26 @@ describe("buildRouteView", () => { expect(view.paths.map((p) => p.poolCount)).toEqual([3, null]); }); + it("counts only the hops that carry a pool", () => { + const view = buildRouteView({ + route: { + ...lifiRoute, + paths: [ + { + share: "1", + hops: [ + { venue: "Aquarius", from: "XLM", to: EURC, pool: "p1" }, + { venue: "stellar-dex", from: EURC, to: USDC }, + ], + }, + ], + }, + tokenContext: emptyContext, + protocols: PROTOCOLS, + })!; + expect(view.paths[0].poolCount).toBe(1); + }); + it("gives a venue /protocols doesn't list no icon", () => { const view = buildRouteView({ route: { diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts index bacaabba8c..74fad9de91 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -179,11 +179,12 @@ export const buildRouteView = ({ resolveRouteToken(path.hops[0].from, tokenContext), ...path.hops.map((hop) => resolveRouteToken(hop.to, tokenContext)), ]; + const pools = path.hops.filter((h) => h.pool).length; return { share: formatShare(path.share), venues, tokens, - poolCount: path.hops.some((h) => h.pool) ? path.hops.length : null, + poolCount: pools > 0 ? pools : null, }; }); From 50bbffd1adce40a7e6b5943a24384030771374f7 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:02:33 -0500 Subject: [PATCH 15/32] fix(swap): match the stellar-dex venue in any casing The backend's stellar-dex venue was matched exactly, while mobile trims and lowercases it. A differently cased value would have shown the raw string with a generic icon here and "Stellar DEX" on mobile. Drift review on #3076 / #1051. Co-Authored-By: Claude Opus 5.5 --- .../SwapDetailsSheet/__tests__/routeViewModel.test.ts | 8 ++++++++ .../components/swap/SwapDetailsSheet/routeViewModel.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts index 73ae389c72..5b3c0c7b1a 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts @@ -9,6 +9,7 @@ import { buildRouteView, formatShare, resolveRouteToken, + resolveVenue, RouteTokenContext, } from "../routeViewModel"; @@ -217,6 +218,13 @@ describe("buildRouteView", () => { }); }); + it("names stellar-dex in any casing Stellar DEX, as mobile does", () => { + expect(resolveVenue(" Stellar-DEX ", PROTOCOLS)).toEqual({ + name: "Stellar DEX", + icon: StellarLogo, + }); + }); + it("names stellar-dex Stellar DEX with the Stellar logo, once in Providers", () => { const view = buildRouteView({ route: classicRoute, diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts index 74fad9de91..ce53b39f11 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -138,7 +138,7 @@ export const resolveVenue = ( protocols: ProtocolEntry[], ): RouteVenue => { // Not in /protocols: it would show up in Discover. - if (venue === "stellar-dex") { + if (venue.trim().toLowerCase() === "stellar-dex") { return { name: "Stellar DEX", icon: StellarLogo }; } const match = protocols.find( From 1ae99a8eb83d4ed5eccbfd1118f4a582005fc342 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:03:08 -0500 Subject: [PATCH 16/32] fix(swap): a venue icon that fails to load shows the generic icon The venue had no error handler, so a stale /protocols icon URL rendered a broken-image glyph in the path rows, the provider chips and the Details route row. It now falls back to the generic route icon, as mobile's SwapVenueIcon does. Code and drift review on #3076. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/SwapDetailsSheet.test.tsx | 18 +++++++++++++++++- .../components/swap/SwapDetailsSheet/index.tsx | 8 ++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx index b9d1e03a06..a6842da4ae 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx @@ -10,7 +10,7 @@ import { import { SwapRoute } from "@shared/api/types/backend-api"; import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { Wrapper } from "popup/__testHelpers__"; -import { SwapDetailsSheet } from "../index"; +import { SwapDetailsSheet, VenueIcon } from "../index"; jest.mock("@shared/api/internal", () => ({ getDiscoverData: jest.fn(), @@ -177,3 +177,19 @@ describe("SwapDetailsSheet", () => { expect(screen.queryByTestId("SwapDetails__route")).toBeNull(); }); }); + +describe("VenueIcon", () => { + it("falls back to the generic icon when the venue's image fails to load", () => { + render( + , + ); + fireEvent.error(screen.getByTestId("SwapDetails__venue-icon")); + + expect( + screen.getByTestId("SwapDetails__venue-icon-generic"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("SwapDetails__venue-icon")).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx index 9d2615fb38..b774895e36 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx +++ b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx @@ -30,13 +30,16 @@ enum Pane { Route = "route", } -export const VenueIcon = ({ venue }: { venue: RouteVenue }) => - venue.icon ? ( +export const VenueIcon = ({ venue }: { venue: RouteVenue }) => { + // /protocols icon URLs go stale; a failed load falls back like a missing one. + const [failed, setFailed] = useState(false); + return venue.icon && !failed ? ( {venue.name} setFailed(true)} /> ) : ( ); +}; const TokenLabel = ({ token }: { token: RouteToken }) => ( From a0f91a574eb634afa45c1491be7bc4f098c153a0 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:04:45 -0500 Subject: [PATCH 17/32] =?UTF-8?q?fix(swap):=20the=20Details=20sheet's=20ra?= =?UTF-8?q?te=20row=20reads=20"Cota=C3=A7=C3=A3o"=20in=20Portuguese?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate row reused the shared "Rate" key, whose Portuguese text is "Taxa", the same word as fee, so the sheet showed two "Taxa" rows next to each other. It now has its own key: "Rate" in English (unchanged) and "Cotação" in Portuguese, the wording mobile uses. The review screen's rate row keeps the shared key. Code review on #3076. Co-Authored-By: Claude Opus 5.5 --- .../SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx | 1 + .../src/popup/components/swap/SwapDetailsSheet/index.tsx | 2 +- .../src/popup/locales/__tests__/translationParity.test.ts | 7 +++++++ extension/src/popup/locales/en/translation.json | 1 + extension/src/popup/locales/pt/translation.json | 1 + 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx index a6842da4ae..a519488b18 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx @@ -174,6 +174,7 @@ describe("SwapDetailsSheet", () => { it("hides the Route row without a route", async () => { await renderSheet(null); expect(screen.getByTestId("SwapDetails__rate")).toBeInTheDocument(); + expect(screen.getByText("Swap details rate")).toBeInTheDocument(); expect(screen.queryByTestId("SwapDetails__route")).toBeNull(); }); }); diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx index b774895e36..3ccae558c1 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx +++ b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx @@ -273,7 +273,7 @@ export const SwapDetailsSheet = ({ ) : null}
- {t("Rate")} + {t("Swap details rate")} { expect(pt).toHaveProperty([k]); }); }); + + // The shared "Rate" key reads "Taxa" (fee) in pt, which sat next to the + // network fee row in the Details sheet. + it("labels the Details sheet's rate as a quote rate, not a fee", () => { + expect(en).toHaveProperty(["Swap details rate"], "Rate"); + expect(pt).toHaveProperty(["Swap details rate"], "Cotação"); + }); }); // i18next only interpolates {{double}} braces. A single-brace placeholder type diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 23a7a19b83..adc4449991 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -649,6 +649,7 @@ "Suspicious Request": "Suspicious Request", "Swap": "Swap", "Swap destination token logo": "Swap destination token logo", + "Swap details rate": "Rate", "Swap direction": "Swap direction", "Swap failed": "Swap failed", "Swap for 0.5 XLM": "Swap for 0.5 XLM", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index f415558b93..a9953f4b35 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -651,6 +651,7 @@ "Suspicious Request": "Solicitação Suspeita", "Swap": "Trocar", "Swap destination token logo": "Logotipo do token de destino da troca", + "Swap details rate": "Cotação", "Swap direction": "Direção da troca", "Swap failed": "Troca falhou", "Swap for 0.5 XLM": "Trocar por 0,5 XLM", From 7b56913ab1c3ddd6533da2b1d99502cf215f3dbb Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:06:27 -0500 Subject: [PATCH 18/32] fix(swap): the XLM-reserve check on Continue counts the resource fee The reserve preflight computed spendable XLM with the inclusion fee alone. On a LI.FI route into a new trustline, an account that could cover the reserve and the inclusion fee, but not the resource fee too, skipped the reserve sheet and failed at submit. It now uses displayFee, the same total the amount screen's fee checks use. It runs on press only, so it doesn't affect quoting. Copilot review on #3076. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.detailsSheet.test.tsx | 26 +++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx index 5ece04d347..e1c7d8b69d 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -330,6 +330,32 @@ describe("SwapAmount details sheet", () => { ); }); + // 0.55 XLM spendable after the inclusion fee clears the 0.5 trustline + // reserve; after the LI.FI resource fee too, it doesn't. + it("opens the XLM-reserve sheet when the resource fee leaves too little for a new trustline", async () => { + withBalances([xlm("1.55"), usdc]); + renderSwapAmount({ + asset: USDC, + destinationAsset: AQUA, + swapQuotes: quotes({ resourceFee: "0.0858459" }), + swapQuoteKey: getSwapQuoteKey({ + mode: "send", + amount: "5", + sourceAsset: USDC, + destAsset: AQUA, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: "G123", + }), + }); + expect(screen.getByTestId("swap-amount-btn-continue")).toHaveTextContent( + "Review swap", + ); + await act(async () => { + fireEvent.click(screen.getByTestId("swap-amount-btn-continue")); + }); + expect(await screen.findByTestId("XlmReserveSheet")).toBeInTheDocument(); + }); + it("lets a non-XLM source through on a classic route", () => { withBalances([xlm("1.0001"), usdc]); renderSwapAmount(nonXlmSource({ source: "classic" })); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 77ec5157ad..122dbd9266 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -324,7 +324,8 @@ export const SwapAmount = ({ spendableXlm: getAvailableBalance({ assetCanonical: "native", balances: sendData.userBalances.balances, - recommendedFee: fee, + // Includes a Soroban route's resource fee, which the XLM also pays. + recommendedFee: displayFee, }), }); if (needsReserve) { From 2c4e79f76c902e7ce87770e315f74e7f2c6105f8 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:10:23 -0500 Subject: [PATCH 19/32] fix(swap): a failed venue icon doesn't hide the next venue's icon The failed flag survived a venue change in the same slot (the hop list keys its rows by index), so after one icon failed, the next venue shown there got the generic icon even with a working URL. The component now remembers which URL failed instead of a flag. Medium review on the C4 fix round. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/SwapDetailsSheet.test.tsx | 19 +++++++++++++++++++ .../swap/SwapDetailsSheet/index.tsx | 9 +++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx index a519488b18..505e36b4a2 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/SwapDetailsSheet.test.tsx @@ -193,4 +193,23 @@ describe("VenueIcon", () => { ).toBeInTheDocument(); expect(screen.queryByTestId("SwapDetails__venue-icon")).toBeNull(); }); + + it("shows the next venue's image after the previous one in the same slot failed", () => { + const { rerender } = render( + , + ); + fireEvent.error(screen.getByTestId("SwapDetails__venue-icon")); + rerender( + , + ); + + expect(screen.getByTestId("SwapDetails__venue-icon")).toHaveAttribute( + "src", + "https://icons.test/soroswap.png", + ); + }); }); diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx index 3ccae558c1..daced5bec5 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx +++ b/extension/src/popup/components/swap/SwapDetailsSheet/index.tsx @@ -31,15 +31,16 @@ enum Pane { } export const VenueIcon = ({ venue }: { venue: RouteVenue }) => { - // /protocols icon URLs go stale; a failed load falls back like a missing one. - const [failed, setFailed] = useState(false); - return venue.icon && !failed ? ( + // /protocols icon URLs go stale; a failed load falls back like a missing + // one. Keyed by URL so another venue rendered in this slot still loads. + const [failedIcon, setFailedIcon] = useState(null); + return venue.icon && venue.icon !== failedIcon ? ( {venue.name} setFailed(true)} + onError={() => setFailedIcon(venue.icon)} /> ) : ( Date: Wed, 11 Nov 2026 22:10:48 -0500 Subject: [PATCH 20/32] fix(swap): match /protocols venue names trimmed, like stellar-dex The stellar-dex check trimmed and lowercased the backend venue, but the /protocols match only lowercased it, so " Soroswap " would show its raw name with no icon. Both comparisons now use one key (trim + lowercase), the same rule as mobile's venueKey. Medium review on the C4 fix round. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/routeViewModel.test.ts | 7 +++++++ .../swap/SwapDetailsSheet/routeViewModel.ts | 15 +++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts index 5b3c0c7b1a..5907d9d0e6 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/__tests__/routeViewModel.test.ts @@ -218,6 +218,13 @@ describe("buildRouteView", () => { }); }); + it("matches a /protocols venue whatever its casing or surrounding spaces", () => { + expect(resolveVenue(" soroswap ", PROTOCOLS)).toEqual({ + name: "Soroswap", + icon: "https://icons.test/soroswap.png", + }); + }); + it("names stellar-dex in any casing Stellar DEX, as mobile does", () => { expect(resolveVenue(" Stellar-DEX ", PROTOCOLS)).toEqual({ name: "Stellar DEX", diff --git a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts index ce53b39f11..bb7609d116 100644 --- a/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts +++ b/extension/src/popup/components/swap/SwapDetailsSheet/routeViewModel.ts @@ -5,10 +5,7 @@ import { AssetType } from "@shared/api/types/account-balance"; import { SwapRoute } from "@shared/api/types/backend-api"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; import { findTokenListIcon } from "@shared/api/helpers/getIconFromTokenList"; -import { - getCanonicalFromAsset, - splitCanonical, -} from "@shared/helpers/stellar"; +import { getCanonicalFromAsset, splitCanonical } from "@shared/helpers/stellar"; import { isNativeAssetPair } from "@shared/helpers/assetIdentity"; import { truncateString } from "helpers/stellar"; import { isContractId } from "popup/helpers/soroban"; @@ -133,17 +130,19 @@ export const resolveRouteToken = ( }; }; +// Backend venue strings and /protocols names compare like mobile's venueKey. +const venueKey = (name: string) => name.trim().toLowerCase(); + export const resolveVenue = ( venue: string, protocols: ProtocolEntry[], ): RouteVenue => { + const key = venueKey(venue); // Not in /protocols: it would show up in Discover. - if (venue.trim().toLowerCase() === "stellar-dex") { + if (key === "stellar-dex") { return { name: "Stellar DEX", icon: StellarLogo }; } - const match = protocols.find( - (p) => p.name.toLowerCase() === venue.toLowerCase(), - ); + const match = protocols.find((p) => venueKey(p.name) === key); return match ? { name: match.name, icon: match.iconUrl || null } : { name: venue, icon: null }; From 8bd9fc3f04eda08a0f4cc03bbb2ad0859e9b862c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:11:44 -0500 Subject: [PATCH 21/32] =?UTF-8?q?fix(swap):=20the=20review's=20rate=20row?= =?UTF-8?q?=20reads=20"Cota=C3=A7=C3=A3o"=20in=20Portuguese=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review sheet's rate row, which only renders on swaps, still used the shared "Rate" key ("Taxa" in pt, the word for fee). It now uses the same swap rate key as the Details sheet. No other screen uses the shared key any more; it stays in the locale files. Medium review on the C4 fix round. Co-Authored-By: Claude Opus 5.5 --- .../ReviewTransaction/components/SwapRateRow.tsx | 2 +- .../components/__tests__/SwapRateRow.test.tsx | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx index b162e035ee..6f12f518ad 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/SwapRateRow.tsx @@ -39,7 +39,7 @@ export const SwapRateRow = ({
- {t("Rate")} + {t("Swap details rate")}
{ ).toBeInTheDocument(); }); + // The shared "Rate" key is "Taxa" (fee) in pt; this row is swap-only. + it("labels the row with the swap rate key, not the shared Rate", () => { + renderRow({}); + expect(screen.getByText("Swap details rate")).toBeInTheDocument(); + }); + it("derives the value font-size from the rendered rate length", () => { renderRow({ srcCode: "LONGTOKENA", From d384ba8afb5ced7109bb315dd61a5113ee97b1eb Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:14:04 -0500 Subject: [PATCH 22/32] chore(i18n): drop the unused Rate key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both swap rate rows now use "Swap details rate" (Cotação in pt), so nothing reads the shared "Rate" key whose pt text, Taxa, meant fee. Co-Authored-By: Claude Opus 5.5 --- .../components/__tests__/SwapRateRow.test.tsx | 3 +-- .../src/popup/locales/__tests__/translationParity.test.ts | 3 +-- extension/src/popup/locales/en/translation.json | 1 - extension/src/popup/locales/pt/translation.json | 1 - 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx index 2560d65ca2..a97b89161c 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/components/__tests__/SwapRateRow.test.tsx @@ -44,8 +44,7 @@ describe("SwapRateRow", () => { ).toBeInTheDocument(); }); - // The shared "Rate" key is "Taxa" (fee) in pt; this row is swap-only. - it("labels the row with the swap rate key, not the shared Rate", () => { + it("labels the row with the swap rate key", () => { renderRow({}); expect(screen.getByText("Swap details rate")).toBeInTheDocument(); }); diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 96c4a68d6f..df950968aa 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -36,8 +36,7 @@ describe("swap i18n parity", () => { }); }); - // The shared "Rate" key reads "Taxa" (fee) in pt, which sat next to the - // network fee row in the Details sheet. + // "Taxa" would read as a second fee next to the network fee row. it("labels the Details sheet's rate as a quote rate, not a fee", () => { expect(en).toHaveProperty(["Swap details rate"], "Rate"); expect(pt).toHaveProperty(["Swap details rate"], "Cotação"); diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index adc4449991..fcfd368b5a 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -523,7 +523,6 @@ "Providers": "Providers", "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", "Quote unavailable": "Quote unavailable", - "Rate": "Rate", "Read before importing your key": "Read before importing your key", "Ready to migrate": "Ready to migrate", "Receive funds from another wallet": "Receive funds from another wallet", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index a9953f4b35..ff34ed2d57 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -525,7 +525,6 @@ "Providers": "Provedores", "Quote has expired, please try again to get a new quote": "A cotação expirou, tente novamente para obter uma nova cotação", "Quote unavailable": "Cotação indisponível", - "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", "Ready to migrate": "Pronto para migrar", "Receive funds from another wallet": "Receber fundos de outra carteira", From 5362cff6aaa98630a26a64c611a2e823c84e5c43 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:34:45 -0500 Subject: [PATCH 23/32] fix(swap): Max holds back the pair's last quoted resource fee Spendable netted out a Soroban route's resource fee only once a quote for the current amount existed. Max changes the amount, so the re-quote then landed with the resource fee and the amount was over the limit: the user had to tap Max twice. The last quote's resource fee is now kept with its pair (swapLastQuotedFee) and, until the current amount's quote lands, used for spendable and Max on the same account, network and assets. With no quote for the pair yet, nothing changes. Display still shows the current quote's fee only. Decision on #3076 (option A), mirrored on mobile. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.detailsSheet.test.tsx | 27 ++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 31 +++++++++++++------ .../src/popup/ducks/transactionSubmission.ts | 14 +++++++++ extension/src/popup/helpers/swapQuote.ts | 6 ++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx index e1c7d8b69d..ef06258350 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -300,6 +300,33 @@ describe("SwapAmount details sheet", () => { expect(savedAmount()).toBe("98.4141341"); }); + describe("before the quote for the current amount lands", () => { + const lastQuoted = (destAsset: string) => ({ + // The stored quote is for another amount, so it isn't current. + amount: "50", + swapQuotes: null, + swapQuoteKey: null, + swapLastQuotedFee: { + key: quoteKey("5", destAsset), + resourceFee: "0.0858459", + }, + }); + + it("Max holds back the pair's last quoted resource fee", () => { + withBalances([xlm("100")]); + renderSwapAmount(lastQuoted(USDC)); + clickMax(); + expect(savedAmount()).toBe("98.4141341"); + }); + + it("Max ignores a resource fee quoted for another pair", () => { + withBalances([xlm("100")]); + renderSwapAmount(lastQuoted(AQUA)); + clickMax(); + expect(savedAmount()).toBe("98.49998"); + }); + }); + it("Max on an XLM source holds back the inclusion fee alone on a classic route", () => { withBalances([xlm("100")]); renderSwapAmount({ swapQuotes: quotes({ source: "classic" }) }); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 122dbd9266..5f1d6a99d7 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -86,6 +86,7 @@ import { getHeldTokenDecimals, getSwapQuote, getSwapQuoteKey, + isSameSwapPair, isSameSwapQuoteKey, pickSwapRoute, toBackendAsset, @@ -480,16 +481,17 @@ export const SwapAmount = ({ // The stored quote lags the inputs until the debounced re-quote lands. The // CTA's pending state reads this too, so hasQuote and "pending" agree. + const inputsKey = getSwapQuoteKey({ + mode: "send", + amount: amount || "0", + sourceAsset: asset, + destAsset: destinationAsset, + networkDetails, + publicKey, + }); const isQuoteForInputs = isSameSwapQuoteKey( transactionData.swapQuoteKey, - getSwapQuoteKey({ - mode: "send", - amount: amount || "0", - sourceAsset: asset, - destAsset: destinationAsset, - networkDetails, - publicKey, - }), + inputsKey, ); const hasQuote = !!srcAsset && @@ -504,6 +506,17 @@ export const SwapAmount = ({ const displayFee = resourceFee ? new BigNumber(fee).plus(resourceFee).toFixed() : fee; + // Until this amount's quote lands, spendable (and so Max) holds back the + // pair's last quoted resource fee: it barely moves with the amount, and + // without it Max overshoots once the quote arrives. + const { swapLastQuotedFee } = transactionData; + const lastResourceFee = + !hasQuote && isSameSwapPair(swapLastQuotedFee?.key, inputsKey) + ? swapLastQuotedFee?.resourceFee + : null; + const balanceFee = lastResourceFee + ? new BigNumber(fee).plus(lastResourceFee).toFixed() + : displayFee; // All balance/price/fee/security/CTA derivation. Plain function (not a hook): // it runs below the early returns, where a hook would violate rules-of-hooks. @@ -536,7 +549,7 @@ export const SwapAmount = ({ amount: formik.values.amount, amountUsd: formik.values.amountUsd, // Max and the XLM fee check hold back what the swap will pay. - fee: displayFee, + fee: balanceFee, blockaidOverrideState, networkDetails, inputType, diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 9770cb79f5..a96211b5b9 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -514,6 +514,12 @@ interface TransactionData { // networks and whenever the quote is cleared. swapQuotes: SwapQuotesResponse | null; swapQuoteKey: SwapQuoteKey | null; + // The best route's resource fee from the last quote, kept past a new + // amount so Max can hold it back before that amount's quote lands. + swapLastQuotedFee: { + key: SwapQuoteKey; + resourceFee: string | null; + } | null; } interface HardwareWalletData { @@ -580,6 +586,7 @@ export const initialState: InitialState = { swapSource: null, swapQuotes: null, swapQuoteKey: null, + swapLastQuotedFee: null, }, transactionSimulation: { response: null, @@ -737,6 +744,13 @@ const transactionSubmissionSlice = createSlice({ action.payload.destinationAmount; state.transactionData.swapQuotes = action.payload.quotes ?? null; state.transactionData.swapQuoteKey = action.payload.key ?? null; + const { quotes, key } = action.payload; + if (quotes && key) { + state.transactionData.swapLastQuotedFee = { + key, + resourceFee: quotes.routes[0]?.resourceFee ?? null, + }; + } }, saveDestinationTokenDetails: ( state, diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index 778ed217f6..1c77cfbb26 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -92,6 +92,12 @@ export const isSameSwapQuoteKey = ( a.destAsset === b.destAsset && new BigNumber(a.amount).isEqualTo(b.amount); +/** Same account, network and asset pair, whatever the amount. */ +export const isSameSwapPair = ( + a: SwapQuoteKey | null | undefined, + b: SwapQuoteKey, +) => !!a && isSameSwapQuoteKey({ ...a, amount: b.amount }, b); + // Classic reasons that mean the source is down rather than that the pair has // no route. const CLASSIC_OUTAGE_REASONS = ["error", "timeout", "rate_limited"]; From bed883f1582604861f1b1c497114fb2488630d29 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 22:36:25 -0500 Subject: [PATCH 24/32] fix(swap): the fee line reads in dollars at a cent or more The line under the cards always showed XLM ("0.0859 XLM network fee"); mobile shows dollars once the fee is worth a cent and XLM below that. The extension now does the same, through a "{{fee}} network fee" key that takes the formatted amount (the XLM-only key is gone). Decision on #3076. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx | 5 +++-- .../__tests__/SwapAmount.quoteUnavailable.test.tsx | 2 +- extension/src/popup/components/swap/SwapAmount/index.tsx | 8 +++++++- extension/src/popup/locales/en/translation.json | 2 +- extension/src/popup/locales/pt/translation.json | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx index ef06258350..c0b75c829a 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.detailsSheet.test.tsx @@ -16,7 +16,7 @@ import * as Internal from "@shared/api/internal"; import * as TokenList from "@shared/api/helpers/token-list"; import { getSwapQuoteKey } from "popup/helpers/swapQuote"; -// Interpolates, so the fee inside "{{fee}} XLM network fee" is checkable. +// Interpolates, so the fee inside "{{fee}} network fee" is checkable. jest.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string, opts?: Record) => @@ -215,8 +215,9 @@ describe("SwapAmount details sheet", () => { expect(screen.getByTestId("swap-amount-fee-display")).toHaveTextContent( "0.0858659 XLM", ); + // $0.0258 at $0.30 per XLM: a cent or more reads in dollars. expect(screen.getByTestId("swap-details-link")).toHaveTextContent( - "0.0858659 XLM network fee", + "$0.03 network fee", ); await act(async () => { fireEvent.click(screen.getByTestId("swap-see-details")); diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx index 9f0f6eb0c4..1bb159641d 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.quoteUnavailable.test.tsx @@ -25,7 +25,7 @@ import * as Internal from "@shared/api/internal"; import * as TokenList from "@shared/api/helpers/token-list"; import { getSwapQuoteKey } from "popup/helpers/swapQuote"; -// Interpolates, so the fee inside "{{fee}} XLM network fee" is checkable. +// Interpolates, so the fee inside "{{fee}} network fee" is checkable. jest.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string, opts?: Record) => diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 5f1d6a99d7..b9c1184484 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -665,6 +665,12 @@ export const SwapAmount = ({ const feeUsd = xlmPrice ? new BigNumber(displayFee).multipliedBy(xlmPrice) : undefined; + // A cent or more reads in dollars, as on mobile; below that "$0.00" says + // nothing, so the line keeps XLM. + const feeLineText = + feeUsd?.isFinite() && feeUsd.gte(0.01) + ? `$${formatAmount(roundUsdValue(feeUsd.toString()))}` + : `${displayFee} XLM`; let feeFiat: string | undefined; if (feeUsd?.isFinite()) { feeFiat = feeUsd.lt(0.01) @@ -971,7 +977,7 @@ export const SwapAmount = ({ data-testid="swap-details-link" > - {t("{{fee}} XLM network fee", { fee: displayFee })} + {t("{{fee}} network fee", { fee: feeLineText })}
{view.paths.map((path, i) => { - const isOpen = expanded === i; + const isOpen = expanded.has(i); return (