diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index e77a5d1dcd..7eacf758d8 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/@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/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..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"]; @@ -62,6 +70,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( @@ -96,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) { @@ -129,6 +142,7 @@ function useGetBalances(options: { networkDetails, assetsListsData, cachedIcons: cachedIconsFromCache, + additionalAssetIds: options.additionalIconAssetIds, }); payload.icons = icons; reduxDispatch(saveTokenLists(assetsListsData)); 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/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/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index cbfa791539..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,7 +130,9 @@ export const getSwapDerivedData = ({ const availableBalance = deductNewTrustlineReserve({ spendable: baseAvailableBalance, sourceIsXlm: asset === "native", - requiresTrustline: destinationTokenDetails?.requiresTrustline ?? false, + // 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/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 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/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 395db4c8c7..bda326ef96 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"; @@ -117,17 +118,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; @@ -144,6 +134,35 @@ 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. + // 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.unfilteredBalances ?? + 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, @@ -160,6 +179,7 @@ export const SwapAmount = ({ transactionFee: fee, transactionTimeout, memo, + destRequiresTrustline, }, }); @@ -193,8 +213,7 @@ export const SwapAmount = ({ destinationRate: dstAssetPrice, }); const needsReserve = shouldShowXlmReservePreflight({ - requiresTrustline: - transactionData.destinationTokenDetails?.requiresTrustline ?? false, + requiresTrustline: destRequiresTrustline, sourceIsXlm: asset === "native", spendableXlm: getAvailableBalance({ assetCanonical: "native", @@ -371,6 +390,7 @@ export const SwapAmount = ({ networkDetails, inputType, isLiveQuoteLoading, + destRequiresTrustline, }); const handleSwapForReserve = async () => { @@ -858,6 +878,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} /> ) : ( <> diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index d653795b3b..0271388c45 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 @@ -96,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()); @@ -107,31 +119,44 @@ 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)); + } + setAreDefaultsApplied(true); + }, [dispatch, location.search, networkDetails.network]); const renderStep = (step: STEPS) => { switch (step) { @@ -235,5 +260,9 @@ export const Swap = () => { } }; + if (!areDefaultsApplied) { + return null; + } + return renderStep(activeStep); }; 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(""); + }); +});