From 9fd9336919995e35936fce31a777fc9e2d18e580 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Wed, 5 Aug 2026 15:37:48 -0400 Subject: [PATCH 1/5] feat(swap): default the You receive token to USDC --- @shared/constants/stellar.ts | 9 + .../components/amount/AmountCard/index.tsx | 3 - .../components/amount/AmountCard/styles.scss | 27 +-- .../SwapAmount/hooks/useGetSwapAmountData.tsx | 44 ++++- extension/src/popup/views/Swap/index.tsx | 39 +++- .../Swap.destinationDefault.test.tsx | 176 ++++++++++++++++++ 6 files changed, 262 insertions(+), 36 deletions(-) create mode 100644 extension/src/popup/views/__tests__/Swap.destinationDefault.test.tsx diff --git a/@shared/constants/stellar.ts b/@shared/constants/stellar.ts index 6fda68e75c..6059c5fd4f 100644 --- a/@shared/constants/stellar.ts +++ b/@shared/constants/stellar.ts @@ -70,6 +70,15 @@ export const DEFAULT_NETWORKS: Array = [ TESTNET_NETWORK_DETAILS, ]; +// Default swap destination ("You receive") per network. Only networks listed +// here get a default; on custom networks the picker starts empty. +export const DEFAULT_SWAP_DEST_CANONICAL: Partial> = { + [NETWORKS.PUBLIC]: + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + [NETWORKS.TESTNET]: + "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", +}; + export const BASE_RESERVE = 0.5 as const; export const BASE_RESERVE_MIN_COUNT = 2 as const; diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 3dc345b0f1..2f53f78ddd 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -256,9 +256,6 @@ export const AmountCard = ({ ) : ( <> - - - {t("Select")} diff --git a/extension/src/popup/components/amount/AmountCard/styles.scss b/extension/src/popup/components/amount/AmountCard/styles.scss index c03511a23f..413437f06d 100644 --- a/extension/src/popup/components/amount/AmountCard/styles.scss +++ b/extension/src/popup/components/amount/AmountCard/styles.scss @@ -113,11 +113,16 @@ margin-bottom: 0; border: 0; color: var(--sds-clr-gray-12); + min-height: pxToRem(36px); &:hover { background-color: var(--sds-clr-gray-06); } + &--empty { + padding: pxToRem(4px) pxToRem(8px) pxToRem(4px) pxToRem(10px); + } + .AccountAssets__asset--logo { width: pxToRem(20px) !important; height: pxToRem(20px) !important; @@ -151,28 +156,6 @@ } } - // Empty "+ Select" state (e.g. the swap receive card before a token is - // picked): a circular badge the same footprint (20px + 4px margin) as the - // asset logo, with a small plus on a slightly lighter background. - &__select-icon { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: pxToRem(20px); - height: pxToRem(20px); - margin: pxToRem(4px); - border-radius: 50%; - background-color: var(--sds-clr-gray-04); - // Muted plus, matching the "You receive" label color. - color: var(--sds-clr-gray-11); - - svg { - width: pxToRem(14px); - height: pxToRem(14px); - } - } - &__asset-code { font-size: pxToRem(14px); font-weight: var(--font-weight-medium); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx index 45ed7515e9..52c8a10eec 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx @@ -1,9 +1,12 @@ import { useReducer } from "react"; +import { useDispatch, useSelector } from "react-redux"; import { initialState, isError, reducer } from "helpers/request"; import { ApiTokenPrices, AssetIcons } from "@shared/api/types"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; import { isContractId } from "popup/helpers/soroban"; +import { getIconFromTokenLists } from "@shared/api/helpers/getIconFromTokenList"; +import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; import { AssetDomains, @@ -15,6 +18,9 @@ import { APPLICATION_STATE } from "@shared/constants/applicationState"; import { isMainnet } from "helpers/stellar"; import { NetworkDetails } from "@shared/constants/stellar"; import { useGetTokenPrices } from "helpers/hooks/useGetTokenPrices"; +import { settingsSelector } from "popup/ducks/settings"; +import { tokensListsSelector, saveTokenLists } from "popup/ducks/cache"; +import { AppDispatch, store } from "popup/App"; export interface ResolvedSwapAmountData { type: AppDataType.RESOLVED; @@ -43,6 +49,8 @@ function useGetSwapAmountData( reducer, initialState, ); + const reduxDispatch = useDispatch(); + const { assetsLists } = useSelector(settingsSelector); const { fetchData: fetchBalances } = useGetBalances({ showHidden: true, includeIcons: false, @@ -102,6 +110,40 @@ function useGetSwapAmountData( tokenPrices = fetchedTokenPrices.tokenPrices || {}; } + // The balances icon map only carries held-token logos, and a destination + // that didn't come through the picker (the network USDC default or a + // destination_asset deep link) has no picker-captured iconUrl either. + // Resolve it from the same token lists the picker uses so both paths + // render the same logo. + let icons = userDomains.balances.icons || {}; + const [dstCode, dstIssuer] = (destinationAsset || "").split(":"); + if (destinationAsset && dstIssuer && !icons[destinationAsset]) { + try { + const cachedLists = tokensListsSelector(store.getState()); + const assetsListsData = cachedLists?.length + ? cachedLists + : await getCombinedAssetListData({ + networkDetails: userDomains.networkDetails, + assetsLists, + cachedAssetLists: [], + }); + if (!cachedLists?.length && assetsListsData.length > 0) { + reduxDispatch(saveTokenLists(assetsListsData)); + } + const { icon } = await getIconFromTokenLists({ + issuerId: isContractId(dstIssuer) ? undefined : dstIssuer, + contractId: isContractId(dstIssuer) ? dstIssuer : undefined, + code: dstCode, + assetsListsData, + }); + if (icon) { + icons = { ...icons, [destinationAsset]: icon }; + } + } catch { + // The logo is cosmetic — never fail the swap screen over it. + } + } + const payload = { type: AppDataType.RESOLVED, applicationState: userDomains.applicationState, @@ -109,7 +151,7 @@ function useGetSwapAmountData( networkDetails: userDomains.networkDetails, userBalances: userDomains.balances, destinationBalances, - icons: userDomains.balances.icons || {}, + icons, domains: userDomains.domains, tokenPrices, } as ResolvedSwapAmountData; diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index d653795b3b..340e717671 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -29,7 +29,12 @@ import { import { navigateTo } from "popup/helpers/navigate"; import { ROUTES } from "popup/constants/routes"; import { resetSimulation } from "popup/ducks/token-payment"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { getAssetFromCanonical } from "helpers/stellar"; +import { + DEFAULT_SWAP_DEST_CANONICAL, + NETWORKS, +} from "@shared/constants/stellar"; // Each swap sub-step emits the consolidated `screen.viewed` event; the step's // identity lives in `screen_name`, declared as a literal below. @@ -70,6 +75,7 @@ export const Swap = () => { const submission = useSelector(transactionSubmissionSelector); const { transactionSimulation, transactionData } = submission; + const networkDetails = useSelector(settingsNetworkDetailsSelector); // Quote expired at submit (op_under_dest_min / op_too_few_offers): recover to // the review screen with a fresh quote instead of dead-ending in SubmitFail. @@ -85,7 +91,8 @@ export const Swap = () => { // from_asset_code/to_asset_code match mobile rather than being canonical ids. emitMetric(METRIC_NAMES.swapQuoteExpired, { from_asset_code: getAssetFromCanonical(transactionData.asset).code, - to_asset_code: getAssetFromCanonical(transactionData.destinationAsset).code, + to_asset_code: getAssetFromCanonical(transactionData.destinationAsset) + .code, result_code: getQuoteExpiredOperationCodes(submission.error).join(", "), }); // Clear only the ERROR status (keep the transaction data + the @@ -107,31 +114,43 @@ export const Swap = () => { const destinationAssetParam = params.get("destination_asset"); // Pre-populate source asset if provided and valid, otherwise default to native + let sourceAsset = "native"; if (sourceAssetParam) { try { getAssetFromCanonical(sourceAssetParam); - dispatch(saveAsset(sourceAssetParam)); + sourceAsset = sourceAssetParam; } catch { // Invalid source asset param, use default - dispatch(saveAsset("native")); - dispatch(saveIsToken(false)); } - } else { - // Set default asset to native if not provided - dispatch(saveAsset("native")); + } + dispatch(saveAsset(sourceAsset)); + if (sourceAsset === "native") { dispatch(saveIsToken(false)); } - // Pre-populate destination asset if provided and valid + // Pre-populate destination asset if provided and valid; otherwise default + // to the network's USDC — or to native when the flow starts from USDC + // itself (e.g. the USDC asset-details screen), so the sides never collide. + let destinationAsset = ""; if (destinationAssetParam) { try { getAssetFromCanonical(destinationAssetParam); - dispatch(saveDestinationAsset(destinationAssetParam)); + destinationAsset = destinationAssetParam; } catch { // Invalid destination asset param, ignore } } - }, [dispatch, location.search]); + if (!destinationAsset) { + const defaultDest = + DEFAULT_SWAP_DEST_CANONICAL[networkDetails.network as NETWORKS]; + if (defaultDest) { + destinationAsset = defaultDest === sourceAsset ? "native" : defaultDest; + } + } + if (destinationAsset) { + dispatch(saveDestinationAsset(destinationAsset)); + } + }, [dispatch, location.search, networkDetails.network]); const renderStep = (step: STEPS) => { switch (step) { diff --git a/extension/src/popup/views/__tests__/Swap.destinationDefault.test.tsx b/extension/src/popup/views/__tests__/Swap.destinationDefault.test.tsx new file mode 100644 index 0000000000..a610a230ce --- /dev/null +++ b/extension/src/popup/views/__tests__/Swap.destinationDefault.test.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { + DEFAULT_SWAP_DEST_CANONICAL, + MAINNET_NETWORK_DETAILS, + NETWORKS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { Wrapper, getTestStore } from "popup/__testHelpers__"; +import { Swap } from "popup/views/Swap"; +import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; +import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as XlmReserve from "popup/helpers/xlmReserve"; + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), + // The Swap view emits screen.viewed on mount; the real emitScreenViewed runs + // buildCommonContext, which reads the Redux auth slice this test's minimal + // store doesn't provide. These tests cover destination defaulting, not + // screen-view analytics, so stub the emit. + emitScreenViewed: jest.fn(), +})); + +const MAINNET_USDC = DEFAULT_SWAP_DEST_CANONICAL[NETWORKS.PUBLIC]!; +const TESTNET_USDC = DEFAULT_SWAP_DEST_CANONICAL[NETWORKS.TESTNET]!; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const swapData = { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: { network: "TESTNET" }, + icons: {}, + userBalances: { balances: [nativeBalance] }, + tokenPrices: {}, +}; + +const renderSwap = ({ + networkDetails, + routes = ["/swap"], +}: { + networkDetails?: {}; + routes?: string[]; +}) => + render( + + + , + ); + +const getDestinationAsset = () => + (getTestStore()!.getState() as any).transactionSubmission.transactionData + .destinationAsset; + +describe("Swap destination default (USDC)", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseSimulateSwapData, "useSimulateTxData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", scanResult: null }, + error: null, + }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { state: RequestState.SUCCESS, data: swapData, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest + .spyOn(XlmReserve, "shouldShowXlmReservePreflight") + .mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("defaults the destination to the network USDC on testnet", async () => { + renderSwap({ networkDetails: TESTNET_NETWORK_DETAILS }); + + await waitFor(() => { + expect(getDestinationAsset()).toBe(TESTNET_USDC); + }); + expect( + within(screen.getByTestId("swap-receive-card")).getByText("USDC"), + ).toBeInTheDocument(); + }); + + it("defaults the destination to the network USDC on mainnet", async () => { + renderSwap({ networkDetails: MAINNET_NETWORK_DETAILS }); + + await waitFor(() => { + expect(getDestinationAsset()).toBe(MAINNET_USDC); + }); + }); + + it("defaults the destination to native when the source is already the network USDC", async () => { + renderSwap({ + networkDetails: TESTNET_NETWORK_DETAILS, + routes: [`/swap?source_asset=${encodeURIComponent(TESTNET_USDC)}`], + }); + + await waitFor(() => { + expect(getDestinationAsset()).toBe("native"); + }); + expect( + within(screen.getByTestId("swap-receive-card")).getByText("XLM"), + ).toBeInTheDocument(); + }); + + it("keeps an explicit destination_asset query param over the default", async () => { + const explicit = + "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; + renderSwap({ + networkDetails: TESTNET_NETWORK_DETAILS, + routes: [`/swap?destination_asset=${encodeURIComponent(explicit)}`], + }); + + await waitFor(() => { + expect(getDestinationAsset()).toBe(explicit); + }); + }); + + it("applies no default on networks without a configured USDC", async () => { + renderSwap({}); + + // The mount effect resets submission and applies no destination; the + // receive card stays in its "Select" empty state. + await waitFor(() => { + expect( + within(screen.getByTestId("swap-receive-card")).getByText("Select"), + ).toBeInTheDocument(); + }); + expect(getDestinationAsset()).toBe(""); + }); +}); From a4ba4f04488610ad252ba39191ce33ead9cbba7a Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Thu, 6 Aug 2026 16:21:20 -0400 Subject: [PATCH 2/5] refactor(swap): derive destination trustline need from balances instead of the pick-time snapshot --- .../SwapAmount/helpers/getSwapDerivedData.ts | 4 +- .../__tests__/useSimulateSwapData.test.ts | 28 +++++------- .../SwapAmount/hooks/useGetSwapAmountData.tsx | 44 +------------------ .../SwapAmount/hooks/useSimulateSwapData.tsx | 29 ++++++------ .../components/swap/SwapAmount/index.tsx | 41 +++++++++++------ 5 files changed, 55 insertions(+), 91 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index cbfa791539..54d1aee9bb 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -124,7 +124,9 @@ export const getSwapDerivedData = ({ const availableBalance = deductNewTrustlineReserve({ spendable: baseAvailableBalance, sourceIsXlm: asset === "native", - requiresTrustline: destinationTokenDetails?.requiresTrustline ?? false, + // Derived from holdings (not the pick-time snapshot) so defaulted and + // deep-linked destinations reserve the trustline bump too. + requiresTrustline: destinationIsNonHeld, }); const displayTotal = `${formatAmount(availableBalance)}`; diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts index 1e90042a09..d18d73a999 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts @@ -76,10 +76,10 @@ describe("getSwapTotalFee", () => { }); describe("getBuiltTx", () => { - it("builds a single pathPaymentStrictSend when not a new token", async () => { + it("builds a single pathPaymentStrictSend for a held destination", async () => { const builder = await getBuiltTx( PUBLIC_KEY, - { ...baseOpData, destinationTokenDetails: null }, + { ...baseOpData, requiresTrustline: false }, "0.00001", 180, TESTNET_NETWORK_DETAILS, @@ -91,18 +91,10 @@ describe("getBuiltTx", () => { expect(builder.baseFee).toBe("100"); // 1 op, full total }); - it("prepends changeTrust as op[0] for a new token", async () => { + it("prepends changeTrust as op[0] for an unheld destination", async () => { const builder = await getBuiltTx( PUBLIC_KEY, - { - ...baseOpData, - destinationTokenDetails: { - tokenCode: "USDC", - requiresTrustline: true, - decimals: 7, - issuer: USDC_ISSUER, - }, - }, + { ...baseOpData, requiresTrustline: true }, "0.0002", 180, TESTNET_NETWORK_DETAILS, @@ -111,21 +103,21 @@ describe("getBuiltTx", () => { const ops = tx.operations; expect(ops).toHaveLength(2); expect(ops[0].type).toBe("changeTrust"); + expect((ops[0] as { line: Asset }).line).toEqual( + new Asset("USDC", USDC_ISSUER), + ); expect(ops[1].type).toBe("pathPaymentStrictSend"); expect(builder.baseFee).toBe("1000"); // 0.0002 XLM / 2 ops }); - it("throws when requiresTrustline but issuer is missing", async () => { + it("throws when requiresTrustline but the destination has no issuer", async () => { await expect( getBuiltTx( PUBLIC_KEY, { ...baseOpData, - destinationTokenDetails: { - tokenCode: "USDC", - requiresTrustline: true, - decimals: 7, - }, + destAsset: Asset.native(), + requiresTrustline: true, }, "0.0002", 180, diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx index 52c8a10eec..45ed7515e9 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx @@ -1,12 +1,9 @@ import { useReducer } from "react"; -import { useDispatch, useSelector } from "react-redux"; import { initialState, isError, reducer } from "helpers/request"; import { ApiTokenPrices, AssetIcons } from "@shared/api/types"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; import { isContractId } from "popup/helpers/soroban"; -import { getIconFromTokenLists } from "@shared/api/helpers/getIconFromTokenList"; -import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; import { AssetDomains, @@ -18,9 +15,6 @@ import { APPLICATION_STATE } from "@shared/constants/applicationState"; import { isMainnet } from "helpers/stellar"; import { NetworkDetails } from "@shared/constants/stellar"; import { useGetTokenPrices } from "helpers/hooks/useGetTokenPrices"; -import { settingsSelector } from "popup/ducks/settings"; -import { tokensListsSelector, saveTokenLists } from "popup/ducks/cache"; -import { AppDispatch, store } from "popup/App"; export interface ResolvedSwapAmountData { type: AppDataType.RESOLVED; @@ -49,8 +43,6 @@ function useGetSwapAmountData( reducer, initialState, ); - const reduxDispatch = useDispatch(); - const { assetsLists } = useSelector(settingsSelector); const { fetchData: fetchBalances } = useGetBalances({ showHidden: true, includeIcons: false, @@ -110,40 +102,6 @@ function useGetSwapAmountData( tokenPrices = fetchedTokenPrices.tokenPrices || {}; } - // The balances icon map only carries held-token logos, and a destination - // that didn't come through the picker (the network USDC default or a - // destination_asset deep link) has no picker-captured iconUrl either. - // Resolve it from the same token lists the picker uses so both paths - // render the same logo. - let icons = userDomains.balances.icons || {}; - const [dstCode, dstIssuer] = (destinationAsset || "").split(":"); - if (destinationAsset && dstIssuer && !icons[destinationAsset]) { - try { - const cachedLists = tokensListsSelector(store.getState()); - const assetsListsData = cachedLists?.length - ? cachedLists - : await getCombinedAssetListData({ - networkDetails: userDomains.networkDetails, - assetsLists, - cachedAssetLists: [], - }); - if (!cachedLists?.length && assetsListsData.length > 0) { - reduxDispatch(saveTokenLists(assetsListsData)); - } - const { icon } = await getIconFromTokenLists({ - issuerId: isContractId(dstIssuer) ? undefined : dstIssuer, - contractId: isContractId(dstIssuer) ? dstIssuer : undefined, - code: dstCode, - assetsListsData, - }); - if (icon) { - icons = { ...icons, [destinationAsset]: icon }; - } - } catch { - // The logo is cosmetic — never fail the swap screen over it. - } - } - const payload = { type: AppDataType.RESOLVED, applicationState: userDomains.applicationState, @@ -151,7 +109,7 @@ function useGetSwapAmountData( networkDetails: userDomains.networkDetails, userBalances: userDomains.balances, destinationBalances, - icons, + icons: userDomains.balances.icons || {}, domains: userDomains.domains, tokenPrices, } as ResolvedSwapAmountData; diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 86879ebf65..9ff6af1dfe 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -76,6 +76,11 @@ interface SimulationParams { transactionFee: string; transactionTimeout: number; memo?: string; + // Derived from balances by the caller (a swap is a self path-payment, so the + // sender needs the destination trustline). Keyed off holdings rather than + // the pick-time snapshot so defaulted/deep-linked destinations — which never + // went through the picker — build the changeTrust op too. + destRequiresTrustline: boolean; } export interface SimulateTxData { @@ -87,13 +92,6 @@ export interface SimulateTxData { export const MIN_PER_OP_FEE = 100; // network minimum, stroops -type DestinationTokenDetails = { - tokenCode: string; - requiresTrustline: boolean; - decimals: number; - issuer?: string; -} | null; - export const getPerOpBaseFee = (totalFee: string, opCount: number): string => { const totalStroops = xlmToStroop(totalFee); const perOp = totalStroops.dividedBy(opCount); @@ -156,7 +154,7 @@ export const getBuiltTx = async ( allowedSlippage: string; destinationAmount: string; path: string[]; - destinationTokenDetails: DestinationTokenDetails; + requiresTrustline: boolean; }, fee: string, transactionTimeout: number, @@ -170,7 +168,7 @@ export const getBuiltTx = async ( allowedSlippage, destinationAmount, path, - destinationTokenDetails, + requiresTrustline, } = opData; const server = stellarSdkServer( networkDetails.networkUrl, @@ -178,10 +176,10 @@ export const getBuiltTx = async ( ); const sourceAccount = await server.loadAccount(publicKey); - const requiresTrustline = !!destinationTokenDetails?.requiresTrustline; const opCount = requiresTrustline ? 2 : 1; - if (requiresTrustline && !destinationTokenDetails?.issuer) { + const destIssuer = destAsset.issuer; + if (requiresTrustline && !destIssuer) { throw new Error( "Cannot add a trustline for a destination token without an issuer", ); @@ -192,12 +190,12 @@ export const getBuiltTx = async ( networkPassphrase: networkDetails.networkPassphrase, }); - if (requiresTrustline) { + if (requiresTrustline && destIssuer) { const Sdk = getSdk(networkDetails.networkPassphrase); transaction.addOperation( buildChangeTrustOperation({ - assetCode: destinationTokenDetails!.tokenCode, - assetIssuer: destinationTokenDetails!.issuer!, + assetCode: destAsset.code, + assetIssuer: destIssuer, sdk: Sdk, }), ); @@ -232,7 +230,6 @@ function useSimulateTxData({ }) { const { memo, - destinationTokenDetails, path: storedPath, destinationAmount: storedDestinationAmount, } = useSelector(transactionDataSelector); @@ -319,7 +316,7 @@ function useSimulateTxData({ destinationAmount, allowedSlippage, path, - destinationTokenDetails, + requiresTrustline: simParams.destRequiresTrustline, }, baseFee.toString(), transactionTimeout, diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index cd4d2dc431..39b706ec42 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -37,6 +37,7 @@ import { openTab } from "popup/helpers/navigate"; import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; import { getAvailableBalance } from "popup/helpers/soroban"; +import { getBalanceCanonicalKey } from "popup/helpers/balance"; import { useBlockaidOverrideState } from "popup/helpers/blockaid"; import { AppDispatch } from "popup/App"; import { emitMetric } from "helpers/metrics"; @@ -120,17 +121,6 @@ export const SwapAmount = ({ transactionFee, transactionTimeout, } = transactionData; - // A new-trustline swap is two ops; scale the recommended default fee by op - // count so each op pays the recommended fee (a custom fee is the total and - // is split per op at build time). - const swapOpCount = transactionData.destinationTokenDetails?.requiresTrustline - ? 2 - : 1; - const fee = getSwapTotalFee({ - recommendedFee, - customFee: transactionFee, - opCount: swapOpCount, - }); // The source can be in the "(+) Select" (empty) state — e.g. after a // direction swap whose destination was unset or a non-held token. const srcAsset = asset ? getAssetFromCanonical(asset) : null; @@ -147,6 +137,31 @@ export const SwapAmount = ({ destinationAsset, asset, ); + + // A swap is a self path-payment, so the sender needs a trustline for the + // destination. Derived from holdings (like the picker derives held-ness) + // rather than the pick-time snapshot, so a defaulted or deep-linked + // destination — which never went through the picker — is covered too. + // Empty until balances resolve; every consumer (fee count, reserve + // preflight, simulation) only acts post-resolve, behind the spinner. + const heldBalances = + swapAmountData.data?.type === AppDataType.RESOLVED + ? swapAmountData.data.userBalances.balances + : []; + const destRequiresTrustline = + Boolean(destinationAsset) && + heldBalances.length > 0 && + !heldBalances.some((b) => getBalanceCanonicalKey(b) === destinationAsset); + + // A new-trustline swap is two ops; scale the recommended default fee by op + // count so each op pays the recommended fee (a custom fee is the total and + // is split per op at build time). + const swapOpCount = destRequiresTrustline ? 2 : 1; + const fee = getSwapTotalFee({ + recommendedFee, + customFee: transactionFee, + opCount: swapOpCount, + }); const { state: simulationState, fetchData: fetchSimulationData, @@ -163,6 +178,7 @@ export const SwapAmount = ({ transactionFee: fee, transactionTimeout, memo, + destRequiresTrustline, }, }); @@ -196,8 +212,7 @@ export const SwapAmount = ({ destinationRate: dstAssetPrice, }); const needsReserve = shouldShowXlmReservePreflight({ - requiresTrustline: - transactionData.destinationTokenDetails?.requiresTrustline ?? false, + requiresTrustline: destRequiresTrustline, sourceIsXlm: asset === "native", spendableXlm: getAvailableBalance({ assetCanonical: "native", From 12d019c531549c85cae424fbc7e68e9d3834d6c1 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Fri, 7 Aug 2026 16:48:33 -0400 Subject: [PATCH 3/5] feat(swap): resolve the default destination's icon via the held-token pipeline --- @shared/api/internal.ts | 38 +++++++++++++++++++ .../hooks/useGetAssetDomainsWithBalances.ts | 1 + .../src/helpers/hooks/useGetBalances.tsx | 4 ++ .../SwapAmount/hooks/useGetSwapAmountData.tsx | 11 +++++- extension/src/popup/views/Swap/index.tsx | 10 +++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 07a4d4b1c7..a220b731d1 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -1178,11 +1178,16 @@ export const getAssetIcons = async ({ networkDetails, assetsListsData, cachedIcons, + additionalAssetIds, }: { balances: Balances; networkDetails?: NetworkDetails; assetsListsData?: AssetListResponse[]; cachedIcons: Record; + // Canonicals to resolve alongside the held balances, even when the account + // doesn't hold them (e.g. the swap flow's default destination). Mirrors + // getTokenPrices' additionalAssetIds. + additionalAssetIds?: string[]; }) => { const assetIcons = {} as { [code: string]: string | null }; const skipLookup = !assetsListsData || !networkDetails; @@ -1242,6 +1247,39 @@ export const getAssetIcons = async ({ } } + // Unheld extras run the same cache -> token lists -> issuer-toml chain as + // the held balances above (toml via the shared domainsToFetch batch below). + for (const canonical of additionalAssetIds || []) { + const [code, key] = canonical.split(":"); + if (!key || canonical in assetIcons) { + // native (no issuer segment) or already covered by a held balance + continue; + } + + const cachedIcon = cachedIcons[canonical]; + if (cachedIcon) { + assetIcons[canonical] = cachedIcon; + continue; + } + if (cachedIcon === null || skipLookup) { + continue; + } + + const tokenListIcon = await getIconFromTokenLists({ + issuerId: isContractId(key) ? undefined : key, + contractId: isContractId(key) ? key : undefined, + code, + assetsListsData, + }); + if (tokenListIcon.icon) { + assetIcons[canonical] = tokenListIcon.icon; + } else if (!isContractId(key)) { + domainsToFetch.push({ key, code }); + } else { + assetIcons[canonical] = null; + } + } + if (domainsToFetch.length > 0 && networkDetails) { const assetDomains = await getAssetDomains({ assetIssuerDomainsToFetch: domainsToFetch.map(({ key }) => key), diff --git a/extension/src/helpers/hooks/useGetAssetDomainsWithBalances.ts b/extension/src/helpers/hooks/useGetAssetDomainsWithBalances.ts index c8ee061f0d..f753b59d05 100644 --- a/extension/src/helpers/hooks/useGetAssetDomainsWithBalances.ts +++ b/extension/src/helpers/hooks/useGetAssetDomainsWithBalances.ts @@ -37,6 +37,7 @@ export type AssetDomains = NeedsReRoute | ResolvedAssetDomains; export function useGetAssetDomainsWithBalances(getBalancesOptions: { showHidden: boolean; includeIcons: boolean; + additionalIconAssetIds?: string[]; }) { const reduxDispatch = useDispatch(); const isSwap = useIsSwap(); diff --git a/extension/src/helpers/hooks/useGetBalances.tsx b/extension/src/helpers/hooks/useGetBalances.tsx index 989db60df9..f3aec24192 100644 --- a/extension/src/helpers/hooks/useGetBalances.tsx +++ b/extension/src/helpers/hooks/useGetBalances.tsx @@ -62,6 +62,9 @@ export interface AccountBalances { function useGetBalances(options: { showHidden: boolean; includeIcons: boolean; + // Canonicals to resolve icons for alongside the held balances (e.g. the + // swap flow's default destination, which the account may not hold). + additionalIconAssetIds?: string[]; }) { const reduxDispatch = useDispatch(); const [state, dispatch] = useReducer( @@ -129,6 +132,7 @@ function useGetBalances(options: { networkDetails, assetsListsData, cachedIcons: cachedIconsFromCache, + additionalAssetIds: options.additionalIconAssetIds, }); payload.icons = icons; reduxDispatch(saveTokenLists(assetsListsData)); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx index 45ed7515e9..134e468923 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx @@ -49,8 +49,15 @@ function useGetSwapAmountData( }); const { fetchData: fetchTokenPrices } = useGetTokenPrices(); - const { fetchData: fetchAssetDomains } = - useGetAssetDomainsWithBalances(options); + // Resolve the destination's icon through the same held-token pipeline + // (cache -> token lists -> issuer toml) even when the account doesn't hold + // it — a defaulted or deep-linked destination never goes through the picker, + // so it has no pick-time iconUrl. Mirrors additionalAssetIds on the + // token-prices fetch below. + const { fetchData: fetchAssetDomains } = useGetAssetDomainsWithBalances({ + ...options, + additionalIconAssetIds: destinationAsset ? [destinationAsset] : undefined, + }); const fetchData = async () => { dispatch({ type: "FETCH_DATA_START" }); diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index 340e717671..0271388c45 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -103,6 +103,11 @@ export const Swap = () => { }, [isQuoteExpiredAtSubmit]); const [inputType, setInputType] = useState("crypto"); + // Children fetch in their own mount effects, and React runs child effects + // before this parent effect — so hold rendering until the reset + defaults + // below have landed in Redux, or the first fetch reads the pre-reset state + // (e.g. no destination default → no destination price/icon on first load). + const [areDefaultsApplied, setAreDefaultsApplied] = useState(false); useEffect(() => { dispatch(resetSimulation()); @@ -150,6 +155,7 @@ export const Swap = () => { if (destinationAsset) { dispatch(saveDestinationAsset(destinationAsset)); } + setAreDefaultsApplied(true); }, [dispatch, location.search, networkDetails.network]); const renderStep = (step: STEPS) => { @@ -254,5 +260,9 @@ export const Swap = () => { } }; + if (!areDefaultsApplied) { + return null; + } + return renderStep(activeStep); }; From ade4498fe22cbdec54551e79887308a57ade2224 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Fri, 14 Aug 2026 14:40:46 -0400 Subject: [PATCH 4/5] fix(swap): show trustline disclosure and emit trustline metric for defaulted destinations --- .../ReviewTx.trustlineBanner.test.tsx | 37 +++++++++++++++++++ .../ReviewTransaction/index.tsx | 18 +++++++-- .../hooks/useSubmitTxData.tsx | 23 +++++++++--- .../components/swap/SwapAmount/index.tsx | 4 ++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx index 878af6b8d1..b05b9a97b9 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/__tests__/ReviewTx.trustlineBanner.test.tsx @@ -76,6 +76,43 @@ describe("ReviewTx trustline banner", () => { ).toBeInTheDocument(); }); + it("renders the banner from the balances-derived flag when there is no pick-time snapshot (defaulted destination)", () => { + render( + + + , + ); + // Token code falls back to the destination canonical (AQUA). + const banner = screen.getByTestId("review-tx-trustline-banner"); + expect(banner).toBeInTheDocument(); + fireEvent.click(banner); + expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument(); + }); + + it("lets the balances-derived flag override a stale snapshot", () => { + render( + + + , + ); + expect( + screen.queryByTestId("review-tx-trustline-banner"), + ).not.toBeInTheDocument(); + }); + it("does not render the banner when no trustline is required", () => { render( diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index c5235216f5..a062e7dee8 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -101,6 +101,11 @@ interface ReviewTxProps { // Friendly per-feature reasons from the source token scan, listed in the // expandable Blockaid pane alongside the transaction-scan reasons. sourceTokenSecurityWarnings?: BlockaidWarning[]; + // Balances-derived "this swap adds a trustline" flag. The pick-time snapshot + // above is absent for defaulted/deep-linked destinations, so callers that + // derive the flag from holdings pass it here; when omitted, the snapshot's + // flag applies (legacy behavior). + requiresTrustline?: boolean; } export const ReviewTx = ({ @@ -119,6 +124,7 @@ export const ReviewTx = ({ destinationTokenDetails, sourceTokenSecurityLevel, sourceTokenSecurityWarnings, + requiresTrustline: requiresTrustlineProp, }: ReviewTxProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -311,7 +317,13 @@ export const ReviewTx = ({ const [isOnFeesPane, setIsOnFeesPane] = useState(false); const [isOnMemoPane, setIsOnMemoPane] = useState(false); - const requiresTrustline = !!destinationTokenDetails?.requiresTrustline; + const requiresTrustline = + requiresTrustlineProp ?? !!destinationTokenDetails?.requiresTrustline; + // A defaulted/deep-linked destination has no pick-time snapshot, so the + // banner/sheet token code falls back to the destination canonical. + const trustlineTokenCode = + destinationTokenDetails?.tokenCode || + (dstAsset ? getAssetFromCanonical(dstAsset.canonical).code : ""); const [isOnTrustlinePane, setIsOnTrustlinePane] = useState(false); // Extract contract ID for custom tokens or collectibles @@ -470,7 +482,7 @@ export const ReviewTx = ({ )} {requiresTrustline && ( setIsOnTrustlinePane(true)} /> )} @@ -683,7 +695,7 @@ export const ReviewTx = ({ modal and appear in place. */} {isOnTrustlinePane ? ( setIsOnTrustlinePane(false)} /> ) : isOnBlockaidSheet ? ( diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx index 0f1a404176..ea89cb0f96 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx @@ -16,6 +16,7 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { emitMetric } from "helpers/metrics"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; +import { getSdk } from "@shared/helpers/stellar"; import { AssetIcons } from "@shared/api/types"; import { allAccountsSelector } from "popup/ducks/accountServices"; @@ -57,7 +58,6 @@ function useSubmitTxData({ destination, federationAddress, destinationAsset, - destinationTokenDetails, isCollectible, collectibleData, }, @@ -110,11 +110,24 @@ function useSubmitTxData({ to_asset_code: getAssetFromCanonical(destinationAsset).code, }); // Trustline added only once the combined changeTrust + - // pathPaymentStrictSend transaction confirmed it. - if (destinationTokenDetails?.requiresTrustline) { + // pathPaymentStrictSend transaction confirmed it. Gate on the + // submitted transaction itself rather than the pick-time snapshot — + // a defaulted/deep-linked destination has no snapshot, but the + // changeTrust op it confirmed is right there in the XDR. + const Sdk = getSdk(networkDetails.networkPassphrase); + const submittedTx = Sdk.TransactionBuilder.fromXDR( + signedXDR, + networkDetails.networkPassphrase, + ); + const changeTrustOp = + "operations" in submittedTx + ? submittedTx.operations.find((op) => op.type === "changeTrust") + : undefined; + if (changeTrustOp && "line" in changeTrustOp) { + const { line } = changeTrustOp; emitMetric(METRIC_NAMES.swapTrustlineAdded, { - asset_code: destinationTokenDetails.tokenCode, - asset_issuer: destinationTokenDetails.issuer, + asset_code: "code" in line ? line.code : undefined, + asset_issuer: "issuer" in line ? line.issuer : undefined, }); } } else { diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 39b706ec42..4cff69bb4a 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -876,6 +876,10 @@ export const SwapAmount = ({ destinationTokenDetails={transactionData.destinationTokenDetails} sourceTokenSecurityLevel={sourceTokenSecurityLevel} sourceTokenSecurityWarnings={sourceTokenSecurityWarnings} + // Balances-derived, so the trustline disclosure also renders for + // defaulted/deep-linked destinations that have no pick-time + // snapshot — the same flag that made getBuiltTx add the op. + requiresTrustline={destRequiresTrustline} /> ) : ( <> From 08c405c45abe3f6a3f9efe6dbb01172b245ffa50 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Fri, 14 Aug 2026 15:25:33 -0400 Subject: [PATCH 5/5] fix(swap): check trustline need against unfiltered balances --- .../src/helpers/hooks/useGetBalances.tsx | 30 ++++++++++++------- .../SwapAmount/helpers/getSwapDerivedData.ts | 12 ++++++-- .../components/swap/SwapAmount/index.tsx | 7 ++++- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/extension/src/helpers/hooks/useGetBalances.tsx b/extension/src/helpers/hooks/useGetBalances.tsx index f3aec24192..8ffd7a0fe6 100644 --- a/extension/src/helpers/hooks/useGetBalances.tsx +++ b/extension/src/helpers/hooks/useGetBalances.tsx @@ -39,20 +39,28 @@ const formatBalances = async ({ balances: NonNullable; showHidden: boolean; }) => { + const unfilteredBalances = sortBalances(balances); if (!showHidden) { const hiddenAssets = await getHiddenAssets({ activePublicKey: publicKey, }); - return sortBalances( - filterHiddenBalances(balances, hiddenAssets.hiddenAssets), - ); - } else { - return sortBalances(balances); + return { + balances: sortBalances( + filterHiddenBalances(balances, hiddenAssets.hiddenAssets), + ), + unfilteredBalances, + }; } + return { balances: unfilteredBalances, unfilteredBalances }; }; export interface AccountBalances { balances: AssetType[]; + // `balances` with no visibility filtering. Hidden assets are a display + // preference; anything that feeds transaction construction (e.g. "does a + // trustline already exist?") must consult this list, or a hidden held asset + // reads as unheld. + unfilteredBalances?: AssetType[]; isFunded: AccountBalancesInterface["isFunded"]; subentryCount: AccountBalancesInterface["subentryCount"]; error?: AccountBalancesInterface["error"]; @@ -99,15 +107,17 @@ function useGetBalances(options: { shouldSkipScan, ); + const { balances, unfilteredBalances } = await formatBalances({ + publicKey, + balances: accountBalances.balances as NonNullable, + showHidden: options.showHidden, + }); const payload = { isFunded: accountBalances.isFunded, subentryCount: accountBalances.subentryCount, error: accountBalances.error, - balances: await formatBalances({ - publicKey, - balances: accountBalances.balances as NonNullable, - showHidden: options.showHidden, - }), + balances, + unfilteredBalances, } as AccountBalances; if (options.includeIcons) { diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index 54d1aee9bb..b907f5f986 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -52,6 +52,11 @@ interface GetSwapDerivedDataParams { networkDetails: NetworkDetails; inputType: InputType; isLiveQuoteLoading: boolean; + // Balances-derived "this swap adds a trustline", checked against the + // UNFILTERED balances (a hidden held asset needs no new trustline). Drives + // the reserve math; distinct from destinationIsNonHeld below, which is a + // display-list concept for the direction toggle. + destRequiresTrustline: boolean; } /** @@ -74,6 +79,7 @@ export const getSwapDerivedData = ({ networkDetails, inputType, isLiveQuoteLoading, + destRequiresTrustline, }: GetSwapDerivedDataParams) => { const sendData = data; const assetIcon = sendData.icons[asset]; @@ -124,9 +130,9 @@ export const getSwapDerivedData = ({ const availableBalance = deductNewTrustlineReserve({ spendable: baseAvailableBalance, sourceIsXlm: asset === "native", - // Derived from holdings (not the pick-time snapshot) so defaulted and - // deep-linked destinations reserve the trustline bump too. - requiresTrustline: destinationIsNonHeld, + // The unfiltered-balances flag, NOT destinationIsNonHeld: a hidden held + // destination builds no changeTrust, so no reserve should be withheld. + requiresTrustline: destRequiresTrustline, }); const displayTotal = `${formatAmount(availableBalance)}`; diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 4cff69bb4a..4b2912498f 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -142,11 +142,15 @@ export const SwapAmount = ({ // destination. Derived from holdings (like the picker derives held-ness) // rather than the pick-time snapshot, so a defaulted or deep-linked // destination — which never went through the picker — is covered too. + // Checked against the UNFILTERED balances: visibility is a display + // preference, and a hidden held asset must not get a redundant changeTrust + // (doubled fee, false reserve preflight, trustline limit reset to default). // Empty until balances resolve; every consumer (fee count, reserve // preflight, simulation) only acts post-resolve, behind the spinner. const heldBalances = swapAmountData.data?.type === AppDataType.RESOLVED - ? swapAmountData.data.userBalances.balances + ? (swapAmountData.data.userBalances.unfilteredBalances ?? + swapAmountData.data.userBalances.balances) : []; const destRequiresTrustline = Boolean(destinationAsset) && @@ -389,6 +393,7 @@ export const SwapAmount = ({ networkDetails, inputType, isLiveQuoteLoading, + destRequiresTrustline, }); const handleSwapForReserve = async () => {