From 00e16abee20f5155289f9b2d9b8acb089e11887c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 12:14:03 -0500 Subject: [PATCH 01/25] feat(swap): read Soroban contract tokens at their own decimals A contract token can have up to 18 decimals (the backend reads decimals() on chain); the swap flow assumed 7 everywhere. - decodeSwapTx accepts contract tokens (a contract token is its own contract ID) and reads the amount sold at the sold token's decimals. A classic path payment for a contract token is refused. - getMinOut rounds to the bought token's decimals: the backend parses minOut at that precision and rejects extra digits. - getSwapTokenDecimals: 7 for classic; a contract token's from the held balance, else from the picked row. The build refuses when a contract destination's decimals are unknown (only reachable via a hand-typed destination_asset URL for a token neither held nor picked). - The sell input, its validation and the build use the source's decimals from balances, no longer via isToken, which a later destination pick overwrites. - Custom networks: a contract token has no route; Horizon isn't asked. - The "custom tokens not supported" error is gone. Co-Authored-By: Claude Opus 5.5 --- .../ReviewTransaction/index.tsx | 2 +- .../SwapAmount.sorobanTokens.test.tsx | 148 ++++++++++++++++++ .../__tests__/swapAmountHelpers.test.ts | 17 +- .../SwapAmount/helpers/getSwapDerivedData.ts | 6 +- .../helpers/swapAmountValidation.ts | 7 +- .../useSimulateSwapData.i18n.test.tsx | 2 + .../useSimulateSwapData.quote.test.tsx | 148 ++++++++++++++++++ .../__tests__/useSimulateSwapData.test.ts | 89 +++-------- .../SwapAmount/hooks/useSimulateSwapData.tsx | 44 +++--- .../components/swap/SwapAmount/index.tsx | 30 ++-- .../src/popup/ducks/transactionSubmission.ts | 4 +- .../helpers/__tests__/decodeSwapTx.test.ts | 94 +++++++++++ .../popup/helpers/__tests__/swapQuote.test.ts | 72 ++++++++- extension/src/popup/helpers/decodeSwapTx.ts | 53 +++++-- extension/src/popup/helpers/swapQuote.ts | 43 ++++- .../src/popup/locales/en/translation.json | 1 - .../src/popup/locales/pt/translation.json | 1 - 17 files changed, 625 insertions(+), 136 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 2f8a1790a7..e30c528df3 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -94,7 +94,7 @@ interface ReviewTxProps { destinationTokenDetails?: { tokenCode: string; requiresTrustline: boolean; - decimals: number; + decimals?: number; issuer?: string; // Blockaid verdict captured when the destination token was picked; folded // into the review security gate alongside the transaction scan. 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 new file mode 100644 index 0000000000..c316ed0d13 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { render } 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 UseNetworkFees from "popup/helpers/useNetworkFees"; +import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; + +const TOKEN_CONTRACT = + "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; +const TOKEN = `TKN:${TOKEN_CONTRACT}`; +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + minimumBalance: "1", + blockaidData: {}, +}; + +const tokenBalance = { + token: { code: "TKN", issuer: { key: TOKEN_CONTRACT } }, + contractId: TOKEN_CONTRACT, + total: new BigNumber("5000000000000000000"), + decimals: 18, + name: "Token", + symbol: "TKN", +}; + +const renderSwapAmount = ( + transactionData: Record, + balances: unknown[] = [nativeBalance], +) => { + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { + type: AppDataType.RESOLVED, + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + networkDetails: MAINNET_NETWORK_DETAILS, + icons: {}, + userBalances: { balances }, + tokenPrices: {}, + }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + return render( + + + , + ); +}; + +describe("SwapAmount with Soroban contract tokens", () => { + let simulate: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + simulate = jest + .spyOn(UseSimulateSwapData, "useSimulateTxData") + .mockReturnValue({ + state: { state: RequestState.IDLE, data: null, error: null }, + isQuoteExpired: false, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + jest + .spyOn(FetchBackendV2, "fetchBackendV2") + .mockResolvedValue({ status: 503, body: {} }); + }); + afterEach(() => jest.restoreAllMocks()); + + const lastSimParams = () => + simulate.mock.calls[simulate.mock.calls.length - 1][0].simParams; + + it("builds with a held contract token's decimals, whatever isToken says", () => { + // A classic destination picked after the source sets isToken to false. + renderSwapAmount({ asset: TOKEN, isToken: false }, [ + nativeBalance, + tokenBalance, + ]); + + expect(lastSimParams()).toMatchObject({ + sourceDecimals: 18, + destDecimals: 7, + }); + }); + + it("builds with the picked row's decimals for a contract token not held", () => { + renderSwapAmount({ + destinationAsset: TOKEN, + destinationTokenDetails: { + tokenCode: "TKN", + issuer: TOKEN_CONTRACT, + requiresTrustline: false, + decimals: 6, + }, + }); + + expect(lastSimParams()).toMatchObject({ + sourceDecimals: 7, + destDecimals: 6, + }); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts index 0387ff17ca..8d209ba2f0 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts @@ -7,17 +7,22 @@ import { describe("validateSwapAmount", () => { it("returns null for a valid amount", () => { - expect(validateSwapAmount("10")).toBeNull(); - expect(validateSwapAmount("0.1234567")).toBeNull(); - expect(validateSwapAmount("")).toBeNull(); + expect(validateSwapAmount("10", 7)).toBeNull(); + expect(validateSwapAmount("0.1234567", 7)).toBeNull(); + expect(validateSwapAmount("", 7)).toBeNull(); }); - it("flags more than 7 decimals", () => { - expect(validateSwapAmount("0.12345678")).toBe(AMOUNT_ERROR.DEC_MAX); + it("flags more decimals than the token has", () => { + expect(validateSwapAmount("0.12345678", 7)).toBe(AMOUNT_ERROR.DEC_MAX); + expect(validateSwapAmount("0.1234567", 6)).toBe(AMOUNT_ERROR.DEC_MAX); + }); + + it("accepts up to an 18-decimal token's precision", () => { + expect(validateSwapAmount("0.123456789012345678", 18)).toBeNull(); }); it("flags an amount above the max send amount", () => { - expect(validateSwapAmount("1000000000000")).toBe(AMOUNT_ERROR.SEND_MAX); + expect(validateSwapAmount("1000000000000", 7)).toBe(AMOUNT_ERROR.SEND_MAX); }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index d5c67cbc03..8856baf44f 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -9,7 +9,7 @@ import { } from "popup/helpers/formatters"; import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; import { isNativeAssetId } from "@shared/helpers/assetIdentity"; -import { getAssetDecimals, getAvailableBalance } from "popup/helpers/soroban"; +import { getAvailableBalance } from "popup/helpers/soroban"; import { findAssetBalance, getBalanceCanonicalKey, @@ -41,7 +41,6 @@ interface GetSwapDerivedDataParams { data: ResolvedSwapAmountData; asset: string; destinationAsset: string; - isToken: boolean; destinationAmount: string; destinationTokenDetails: DestinationTokenDetails | null; /** formik.values.amount */ @@ -71,7 +70,6 @@ export const getSwapDerivedData = ({ data, asset, destinationAsset, - isToken, destinationAmount, destinationTokenDetails, amount, @@ -114,7 +112,6 @@ export const getSwapDerivedData = ({ (isMainnet(data.networkDetails) && dstSpotPrice != null ? String(dstSpotPrice) : undefined); - const assetDecimals = getAssetDecimals(asset, sendData.userBalances, isToken); const priceValueUsd = assetPrice ? formatUsdValue(assetPrice, amount) : null; const supportsUsd = isMainnet(data.networkDetails) && assetPrice; const dstPriceValueUsd = dstAssetPrice @@ -226,7 +223,6 @@ export const getSwapDerivedData = ({ destinationIsNonHeld, assetPrice, dstAssetPrice, - assetDecimals, priceValueUsd, supportsUsd, dstPriceValueUsd, diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts b/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts index f930a0ca48..ca408802e6 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts @@ -8,9 +8,12 @@ import { AMOUNT_ERROR } from "helpers/transaction"; * Returns an AMOUNT_ERROR key when the sell amount is invalid (too many decimals * or over the send cap), else null. Kept pure so it can be unit-tested directly. */ -export const validateSwapAmount = (rawAmount: string): string | null => { +export const validateSwapAmount = ( + rawAmount: string, + decimals: number, +): string | null => { const val = cleanAmount(rawAmount); - if (val.indexOf(".") !== -1 && val.split(".")[1].length > 7) { + if (val.indexOf(".") !== -1 && val.split(".")[1].length > decimals) { return AMOUNT_ERROR.DEC_MAX; } if (new BigNumber(val).gt(new BigNumber(TX_SEND_MAX))) { diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.i18n.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.i18n.test.tsx index 6590f584a5..239a07fd51 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.i18n.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.i18n.test.tsx @@ -125,6 +125,8 @@ const renderSimulate = ( transactionFee: "0.00001", transactionTimeout: 180, destRequiresTrustline: false, + sourceDecimals: 7, + destDecimals: 7, }, }), { diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx index 26afd63251..4b1997486a 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx @@ -3,10 +3,14 @@ import { captureException } from "@sentry/browser"; import { act, renderHook } from "@testing-library/react"; import { Account, + Address, Asset, + nativeToScVal, Networks, Operation, + StrKey, TransactionBuilder, + xdr, } from "stellar-sdk"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; @@ -137,12 +141,76 @@ const txRequest = (spy: jest.SpyInstance) => const quoteCalls = (spy: jest.SpyInstance) => spy.mock.calls.filter(([{ path }]) => path.startsWith("/swap/quotes")); +const TOKEN_CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 9)); +const TOKEN = `TKN:${TOKEN_CONTRACT}`; +const ROUTER = StrKey.encodeContract(Buffer.alloc(32, 3)); + +const backendContract = (a: string) => + StrKey.isValidContract(a) ? a : backendAsset(a).contractId(Networks.PUBLIC); + +// A Soroban swap whose auth tree lets the router take `units` of fromAsset. +const sorobanBuild = (units: string) => (req: any) => { + const call = (contract: string, fn: string, args: xdr.ScVal[]) => + new xdr.InvokeContractArgs({ + contractAddress: new Address(contract).toScAddress(), + functionName: fn, + args, + }); + const swap = call(ROUTER, "swap", []); + const transfer = call(backendContract(req.fromAsset), "transfer", [ + new Address(req.account).toScVal(), + new Address(ROUTER).toScVal(), + nativeToScVal(BigInt(units), { type: "i128" }), + ]); + const invocation = (fn: xdr.InvokeContractArgs, subs: any[] = []) => + new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + fn, + ), + subInvocations: subs, + }); + return new TransactionBuilder(new Account(req.account, "1"), { + fee: "100", + networkPassphrase: Networks.PUBLIC, + }) + .addOperation( + Operation.invokeHostFunction({ + func: xdr.HostFunction.hostFunctionTypeInvokeContract(swap), + auth: [ + new xdr.SorobanAuthorizationEntry({ + credentials: + xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: invocation(swap, [invocation(transfer)]), + }), + ], + }), + ) + .setTimeout(req.timeout) + .build() + .toXDR(); +}; + +const lifiRoute = (from: string, to: string, toAmount: string) => ({ + routes: [ + { + source: "lifi", + fromAmount: "1", + toAmount, + paths: [{ share: "1", hops: [{ venue: "Aquarius", from, to }] }], + fees: [], + }, + ], + unavailable: [], +}); + const renderSimulate = ( stored: Record = { // No live quote yet: the hook must fetch one. destinationAmount: "", path: [], }, + params: Record = {}, ) => renderHook( () => @@ -158,6 +226,9 @@ const renderSimulate = ( transactionFee: "0.00001", transactionTimeout: 180, destRequiresTrustline: false, + sourceDecimals: 7, + destDecimals: 7, + ...params, }, }), { @@ -573,6 +644,83 @@ describe("useSimulateTxData quote when no live quote exists", () => { expect(result.current.state.error).toBe("No path found for swap."); }); + describe("Soroban contract tokens", () => { + const tokenAsset = getAssetFromCanonical(TOKEN); + + it("sells a contract token at its own decimals", async () => { + fetchBackendV2.mockImplementation( + answer(lifiRoute(TOKEN_CONTRACT, USDC, "2"), { + status: 200, + build: sorobanBuild("1123456"), + }), + ); + const { result } = renderSimulate(undefined, { + sourceAsset: tokenAsset, + sourceDecimals: 6, + }); + + let payload: any; + await act(async () => { + payload = await result.current.fetchData({ amount: "1.123456" }); + }); + + expect(result.current.state.error).toBeNull(); + expect(txRequest(fetchBackendV2)).toMatchObject({ + fromAsset: TOKEN_CONTRACT, + toAsset: USDC, + amount: "1.123456", + }); + expect(payload.decoded.sendAmount).toBe("1.123456"); + }); + + it("rounds minOut to the bought token's decimals", async () => { + fetchBackendV2.mockImplementation( + answer(lifiRoute("XLM", TOKEN_CONTRACT, "2.345678"), { + status: 200, + build: sorobanBuild("10000000"), + }), + ); + const { result } = renderSimulate(undefined, { + destAsset: tokenAsset, + destDecimals: 6, + }); + + await act(async () => { + await result.current.fetchData({ amount: "1" }); + }); + + // 2.345678 less 1% = 2.32222122, down to 6 decimals + expect(txRequest(fetchBackendV2)).toMatchObject({ + toAsset: TOKEN_CONTRACT, + minOut: "2.322221", + }); + }); + + it("does not build into a contract token whose decimals are unknown", async () => { + fetchBackendV2.mockImplementation( + answer(lifiRoute("XLM", TOKEN_CONTRACT, "2.345678"), { + status: 200, + build: sorobanBuild("10000000"), + }), + ); + const { result } = renderSimulate(undefined, { + destAsset: tokenAsset, + destDecimals: undefined, + }); + + await act(async () => { + await result.current.fetchData({ amount: "1" }); + }); + + expect( + fetchBackendV2.mock.calls.some(([{ path }]) => + path.startsWith("/swap/tx"), + ), + ).toBe(false); + expect(result.current.state.error).toBeTruthy(); + }); + }); + describe("with a stored live quote", () => { const opOf = (payload: any) => ( 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 48d52d1fab..e1abbe39da 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 @@ -38,7 +38,14 @@ describe("getMinOut", () => { ["100", "7", "93"], ["3", "0.7", "2.979"], ])("takes %s less %s%% exactly, rounded down", (toAmount, slip, minOut) => { - expect(getMinOut(slip, toAmount)).toBe(minOut); + expect(getMinOut(slip, toAmount, 7)).toBe(minOut); + }); + + it.each([ + ["2.345678", 6, "2.322221"], + ["0.000000000000000123", 18, "0.000000000000000121"], + ])("rounds %s down to %s decimals", (toAmount, decimals, minOut) => { + expect(getMinOut("1", toAmount, decimals)).toBe(minOut); }); }); @@ -138,92 +145,34 @@ describe("getBuiltTx", () => { }); }); -const CONTRACT_ID = "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7"; -const CLASSIC_ISSUER = - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; - -const classicAsset = { issuer: CLASSIC_ISSUER }; -const contractAsset = { issuer: CONTRACT_ID }; - describe("getSwapErrorMessage", () => { - it("returns custom token error when source is a contract ID", () => { - const result = getSwapErrorMessage( - new Error("some error"), - contractAsset, - classicAsset, - ); - expect(result).toBe(ERROR_TO_DISPLAY.CUSTOM_TOKEN_NOT_SUPPORTED); - }); - - it("returns custom token error when dest is a contract ID", () => { - const result = getSwapErrorMessage( - new Error("some error"), - classicAsset, - contractAsset, - ); - expect(result).toBe(ERROR_TO_DISPLAY.CUSTOM_TOKEN_NOT_SUPPORTED); - }); - - it("returns custom token error when both are contract IDs", () => { - const result = getSwapErrorMessage( - new Error("some error"), - contractAsset, - contractAsset, - ); - expect(result).toBe(ERROR_TO_DISPLAY.CUSTOM_TOKEN_NOT_SUPPORTED); - }); - - it("returns known error even when assets are contract IDs", () => { - const result = getSwapErrorMessage( - new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND), - contractAsset, - classicAsset, - ); - expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); - }); - - it("returns known error message for classic assets", () => { - const result = getSwapErrorMessage( - new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND), - classicAsset, - classicAsset, + it("returns a known error message", () => { + expect(getSwapErrorMessage(new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND))).toBe( + ERROR_TO_DISPLAY.NO_PATH_FOUND, ); - expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); }); - it("returns unknown error for unrecognized Error with classic assets", () => { - const result = getSwapErrorMessage( - new Error("something unexpected"), - classicAsset, - classicAsset, - ); + it("returns unknown error for unrecognized Error", () => { + const result = getSwapErrorMessage(new Error("something unexpected")); expect(result).toBe( "We had an issue retrieving your transaction details. Please try again.", ); }); - it("returns known error message for string errors with classic assets", () => { - const result = getSwapErrorMessage( - ERROR_TO_DISPLAY.NO_PATH_FOUND, - classicAsset, - classicAsset, - ); + it("returns known error message for string errors", () => { + const result = getSwapErrorMessage(ERROR_TO_DISPLAY.NO_PATH_FOUND); expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); }); - it("returns unknown error for unrecognized string with classic assets", () => { - const result = getSwapErrorMessage( - "something unexpected", - classicAsset, - classicAsset, - ); + it("returns unknown error for unrecognized string", () => { + const result = getSwapErrorMessage("something unexpected"); expect(result).toBe( "We had an issue retrieving your transaction details. Please try again.", ); }); - it("returns unknown error for non-Error non-string with classic assets", () => { - const result = getSwapErrorMessage(42, classicAsset, classicAsset); + it("returns unknown error for non-Error non-string", () => { + const result = getSwapErrorMessage(42); expect(result).toBe( "We had an issue retrieving your transaction details. Please try again.", ); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index ebaa81cde2..0c785058c5 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -45,7 +45,7 @@ import { } from "popup/helpers/swapQuote"; import { decodeSwapTx, DecodedSwapTx } from "popup/helpers/decodeSwapTx"; import { isQuoteExpiredError } from "popup/helpers/quoteExpiry"; -import { isContractId } from "popup/helpers/soroban"; +import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { AppDispatch } from "popup/App"; @@ -53,7 +53,6 @@ const scanUrlstub = "internal"; export const ERROR_TO_DISPLAY = { NO_PATH_FOUND: "No path found for swap.", - CUSTOM_TOKEN_NOT_SUPPORTED: "Swapping custom tokens is not supported yet.", SWAPS_UNAVAILABLE: "Swaps are temporarily unavailable", NO_QUOTE: "No quote available", QUOTE_EXPIRED: "Quote has expired, please try again to get a new quote", @@ -62,12 +61,7 @@ export const ERROR_TO_DISPLAY = { const UNKNOWN_ERROR_DISPLAY = "We had an issue retrieving your transaction details. Please try again."; -export const getSwapErrorMessage = ( - error: unknown, - sourceAsset: { issuer?: string }, - destAsset: { issuer?: string }, -): string => { - // Surface known error messages first, regardless of asset type +export const getSwapErrorMessage = (error: unknown): string => { const errorStr = error instanceof Error ? error.message @@ -77,10 +71,6 @@ export const getSwapErrorMessage = ( if (errorStr && Object.values(ERROR_TO_DISPLAY).includes(errorStr)) { return errorStr; } - // For unrecognized errors involving contract-ID assets, show a specific message - if (isContractId(sourceAsset.issuer) || isContractId(destAsset.issuer)) { - return ERROR_TO_DISPLAY.CUSTOM_TOKEN_NOT_SUPPORTED; - } return UNKNOWN_ERROR_DISPLAY; }; @@ -98,6 +88,9 @@ interface SimulationParams { // the pick-time snapshot so defaulted/deep-linked destinations — which never // went through the picker — build the changeTrust op too. destRequiresTrustline: boolean; + sourceDecimals: number; + /** Unknown for a contract token the account doesn't hold and didn't pick. */ + destDecimals?: number; } export interface SimulateTxData { @@ -243,17 +236,23 @@ export const getBuiltTx = async ( // Exact decimal arithmetic, rounded down: computeDestMinWithSlippage's float // multiplier can miss the exact floor by a stroop. -export const getMinOut = (allowedSlippage: string, toAmount: string) => +// The backend refuses a minOut with more decimals than the bought token has. +export const getMinOut = ( + allowedSlippage: string, + toAmount: string, + decimals: number, +) => new BigNumber(toAmount) .times(new BigNumber(100).minus(allowedSlippage)) .div(100) - .decimalPlaces(7, BigNumber.ROUND_DOWN) + .decimalPlaces(decimals, BigNumber.ROUND_DOWN) .toFixed(); const buildFromBackend = async ({ quotes, key, allowedSlippage, + destDecimals, fee, timeout, onQuoteMoved, @@ -261,6 +260,7 @@ const buildFromBackend = async ({ quotes: SwapQuotesResponse | null; key: SwapQuoteKey; allowedSlippage: string; + destDecimals?: number; fee: string; timeout: number; onQuoteMoved: () => void; @@ -269,7 +269,10 @@ const buildFromBackend = async ({ if (!route) { throw new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND); } - const minOut = getMinOut(allowedSlippage, route.toAmount); + if (destDecimals === undefined) { + throw new Error(`Swap: unknown decimals for ${key.destAsset}`); + } + const minOut = getMinOut(allowedSlippage, route.toAmount, destDecimals); try { const { xdr } = await buildSwapTx({ network: key.network, @@ -449,12 +452,17 @@ function useSimulateTxData({ ) .build() .toXdr(), - minOut: getMinOut(allowedSlippage, destinationAmount), + minOut: getMinOut( + allowedSlippage, + destinationAmount, + CLASSIC_ASSET_DECIMALS, + ), } : await buildFromBackend({ quotes, key: getSwapQuoteKey(quoteParams), allowedSlippage, + destDecimals: simParams.destDecimals, fee: baseFee.toFixed(), timeout: transactionTimeout, onQuoteMoved: () => { @@ -471,6 +479,7 @@ function useSimulateTxData({ sourceAsset: quoteParams.sourceAsset, destAsset: quoteParams.destAsset, minOut, + sourceDecimals: simParams.sourceDecimals, }); } catch (e) { if (!isStale()) { @@ -509,9 +518,8 @@ function useSimulateTxData({ if (isStale()) { return error; } - const { sourceAsset, destAsset } = simParams; // Every message is a key in the locale files. - const payload = t(getSwapErrorMessage(error, sourceAsset, destAsset)); + const payload = t(getSwapErrorMessage(error)); setIsQuoteExpired( (error instanceof Error && diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index b96a0f5663..e103161906 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -38,7 +38,10 @@ import { AppDataType } from "helpers/hooks/useGetAppData"; import { closeWindow, openTab } from "popup/helpers/navigate"; import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; -import { getAvailableBalance } from "popup/helpers/soroban"; +import { + CLASSIC_ASSET_DECIMALS, + getAvailableBalance, +} from "popup/helpers/soroban"; import { getBalanceCanonicalKey } from "popup/helpers/balance"; import { useBlockaidOverrideState } from "popup/helpers/blockaid"; import { AppDispatch } from "popup/App"; @@ -73,7 +76,7 @@ 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 { getSwapQuote } from "popup/helpers/swapQuote"; +import { getSwapQuote, getSwapTokenDecimals } from "popup/helpers/swapQuote"; import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote"; @@ -119,7 +122,6 @@ export const SwapAmount = ({ destination, destinationAmount, destinationAsset, - isToken, memo, path, transactionFee, @@ -156,6 +158,14 @@ export const SwapAmount = ({ ? (swapAmountData.data.userBalances.unfilteredBalances ?? swapAmountData.data.userBalances.balances) : []; + const sourceDecimals = + getSwapTokenDecimals({ canonical: asset, balances: heldBalances }) ?? + CLASSIC_ASSET_DECIMALS; + const destDecimals = getSwapTokenDecimals({ + canonical: destinationAsset, + balances: heldBalances, + picked: transactionData.destinationTokenDetails, + }); const destRequiresTrustline = Boolean(destinationAsset) && heldBalances.length > 0 && @@ -187,6 +197,8 @@ export const SwapAmount = ({ transactionTimeout, memo, destRequiresTrustline, + sourceDecimals, + destDecimals, }, }); @@ -287,7 +299,7 @@ export const SwapAmount = ({ }; const validate = (values: { amount: string }) => { - const error = validateSwapAmount(values.amount); + const error = validateSwapAmount(values.amount, sourceDecimals); return error ? { amount: error } : {}; }; @@ -425,7 +437,6 @@ export const SwapAmount = ({ destinationIsNonHeld, assetPrice, dstAssetPrice, - assetDecimals, priceValueUsd, supportsUsd, dstPriceValueUsd, @@ -442,7 +453,6 @@ export const SwapAmount = ({ data, asset, destinationAsset, - isToken, destinationAmount, destinationTokenDetails: transactionData.destinationTokenDetails, amount: formik.values.amount, @@ -703,7 +713,7 @@ export const SwapAmount = ({ })} isAmountTooHigh={isAmountTooHigh} maxSpendableText={displayTotal} - cryptoDecimals={assetDecimals} + cryptoDecimals={sourceDecimals} onAmountChange={({ amount: newAmount }) => { // Normalize a cleared input back to the canonical "0". const v = newAmount === "" ? DEFAULT_AMOUNT : newAmount; @@ -720,7 +730,7 @@ export const SwapAmount = ({ const crypto = assetPrice ? new BigNumber(cleanAmount(usd)) .dividedBy(assetPrice) - .decimalPlaces(assetDecimals) + .decimalPlaces(sourceDecimals) .toFixed() : DEFAULT_AMOUNT; formik.setFieldValue("amount", crypto); @@ -824,7 +834,7 @@ export const SwapAmount = ({ isAmountTooHigh={false} isReadOnly autoFocus={false} - cryptoDecimals={7} + cryptoDecimals={destDecimals ?? CLASSIC_ASSET_DECIMALS} onAmountChange={() => {}} onAmountUsdChange={() => {}} onToggleInputType={() => {}} @@ -859,7 +869,7 @@ export const SwapAmount = ({ cleanAmount(availableBalance), ) .multipliedBy(new BigNumber(pct).dividedBy(100)) - .decimalPlaces(assetDecimals) + .decimalPlaces(sourceDecimals) .toFixed(); formik.setFieldValue("amount", pctAmount); dispatch(saveAmount(pctAmount)); diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 40afff4e75..3df527d30b 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -487,8 +487,8 @@ export interface DestinationTokenDetails { tokenCode: string; // true when the user has no trustline for this destination yet. requiresTrustline: boolean; - // 7 for classic assets. - decimals: number; + // 7 for classic assets; unset when the picked row didn't say. + decimals?: number; // omitted for native XLM. issuer?: string; // from the pick-time Blockaid bulk scan. diff --git a/extension/src/popup/helpers/__tests__/decodeSwapTx.test.ts b/extension/src/popup/helpers/__tests__/decodeSwapTx.test.ts index 03c3e2f66d..f98bd8ca4a 100644 --- a/extension/src/popup/helpers/__tests__/decodeSwapTx.test.ts +++ b/extension/src/popup/helpers/__tests__/decodeSwapTx.test.ts @@ -62,8 +62,47 @@ const decode = (transactionXdr: string, account = ACCOUNT, minOut = "1.715") => sourceAsset: "native", destAsset: USDC, minOut, + sourceDecimals: 7, }); +const TOKEN_CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 9)); +const TOKEN = `TKN:${TOKEN_CONTRACT}`; + +// One invoke from ACCOUNT whose auth tree lets `soldContract` move `amount`. +const sorobanTx = (soldContract: string, amount: bigint) => { + const call = (contract: string, fn: string, args: xdr.ScVal[]) => + new xdr.InvokeContractArgs({ + contractAddress: new Address(contract).toScAddress(), + functionName: fn, + args, + }); + const swap = call(OTHER_CONTRACT, "swap", [nativeToScVal(1)]); + const transfer = call(soldContract, "transfer", [ + new Address(ACCOUNT).toScVal(), + new Address(OTHER_CONTRACT).toScVal(), + i128(amount), + ]); + const invocation = (fn: xdr.InvokeContractArgs, subs: any[] = []) => + new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + fn, + ), + subInvocations: subs, + }); + return txOf([ + Operation.invokeHostFunction({ + func: xdr.HostFunction.hostFunctionTypeInvokeContract(swap), + auth: [ + new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: invocation(swap, [invocation(transfer)]), + }), + ], + }), + ]); +}; + // Rewrites one field of the LI.FI fixture's swap(params, …) call. const lifiWith = (edit: (params: any) => void) => { const env = xdr.TransactionEnvelope.fromXDR(lifi.withTrust.xdr, "base64"); @@ -173,6 +212,61 @@ describe("decodeSwapTx", () => { ).toThrow(/transfer/); }); + describe("Soroban contract tokens", () => { + const decodeToken = ( + transactionXdr: string, + sourceAsset: string, + destAsset: string, + sourceDecimals: number, + ) => + decodeSwapTx({ + transactionXdr, + networkPassphrase: Networks.PUBLIC, + account: ACCOUNT, + sourceAsset, + destAsset, + minOut: "1", + sourceDecimals, + }); + + it("reads the amount sold of a contract token at its own decimals", () => { + const decoded = decodeToken( + sorobanTx(TOKEN_CONTRACT, BigInt("1500000000000000000")), + TOKEN, + USDC, + 18, + ); + + expect(decoded.sendAmount).toBe("1.5"); + expect(decoded.addsTrustline).toBe(false); + }); + + it("reads a swap into a contract token", () => { + const xlmContract = Asset.native().contractId(Networks.PUBLIC); + + expect( + decodeToken( + sorobanTx(xlmContract, BigInt(25_000_000)), + "native", + TOKEN, + 7, + ).sendAmount, + ).toBe("2.5"); + }); + + it("refuses a transfer of a token other than the one sold", () => { + expect(() => + decodeToken(sorobanTx(OTHER_CONTRACT, BigInt(1)), TOKEN, USDC, 18), + ).toThrow(/transfer/); + }); + + it("refuses a classic path payment for a contract token", () => { + expect(() => decodeToken(classicTx(), "native", TOKEN, 7)).toThrow( + /does not match/, + ); + }); + }); + describe("minimum received", () => { it("refuses a classic destMin below the minOut sent", () => { expect(() => decode(classicTx(), ACCOUNT, "1.7150001")).toThrow( diff --git a/extension/src/popup/helpers/__tests__/swapQuote.test.ts b/extension/src/popup/helpers/__tests__/swapQuote.test.ts index a9c6153302..3505ee1736 100644 --- a/extension/src/popup/helpers/__tests__/swapQuote.test.ts +++ b/extension/src/popup/helpers/__tests__/swapQuote.test.ts @@ -7,7 +7,7 @@ import { captureException } from "@sentry/browser"; import * as GetSwapQuotes from "@shared/api/helpers/getSwapQuotes"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; -import { getSwapQuote } from "../swapQuote"; +import { getSwapQuote, getSwapTokenDecimals } from "../swapQuote"; jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); @@ -269,6 +269,28 @@ describe("getSwapQuote", () => { }); describe("on a custom network", () => { + it.each([ + ["sold", `TKN:${TOKEN_CONTRACT}`, USDC], + ["bought", "native", `TKN:${TOKEN_CONTRACT}`], + ])( + "has no route, without asking Horizon, when a contract token is %s", + async (_side, sourceAsset, destAsset) => { + horizonSend.mockRejectedValue(new Error("Horizon must not be asked")); + + const result = await getSwapQuote({ + mode: "send", + amount: "1", + sourceAsset, + destAsset, + networkDetails: CUSTOM_NETWORK_DETAILS, + publicKey: ACCOUNT, + }); + + expect(horizonSend).not.toHaveBeenCalled(); + expect(result.route).toBeNull(); + }, + ); + it("keeps Horizon strict-send path finding", async () => { horizonSend.mockResolvedValue({ source_amount: "100", @@ -359,3 +381,51 @@ describe("getSwapQuote", () => { }); }); }); + +describe("getSwapTokenDecimals", () => { + const TOKEN = `TKN:${TOKEN_CONTRACT}`; + const heldToken = { + token: { code: "TKN", issuer: { key: TOKEN_CONTRACT } }, + contractId: TOKEN_CONTRACT, + decimals: 18, + } as any; + + it.each([ + ["XLM", "native"], + ["a classic asset", USDC], + ])("is 7 for %s", (_name, canonical) => { + expect( + getSwapTokenDecimals({ + canonical, + balances: [], + picked: { decimals: 3 }, + }), + ).toBe(7); + }); + + it("reads a held contract token's decimals from its balance", () => { + expect( + getSwapTokenDecimals({ + canonical: TOKEN, + balances: [heldToken], + picked: { decimals: 6 }, + }), + ).toBe(18); + }); + + it("falls back to the picked row's decimals", () => { + expect( + getSwapTokenDecimals({ + canonical: TOKEN, + balances: [], + picked: { decimals: 6 }, + }), + ).toBe(6); + }); + + it("is unknown for a contract token neither held nor picked", () => { + expect( + getSwapTokenDecimals({ canonical: TOKEN, balances: [] }), + ).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/helpers/decodeSwapTx.ts b/extension/src/popup/helpers/decodeSwapTx.ts index b9ff5b8f89..99471d7bee 100644 --- a/extension/src/popup/helpers/decodeSwapTx.ts +++ b/extension/src/popup/helpers/decodeSwapTx.ts @@ -5,6 +5,7 @@ import { FeeBumpTransaction, Operation, scValToNative, + StrKey, Transaction, TransactionBuilder, xdr, @@ -24,23 +25,37 @@ export interface DecodedSwapTx { addsTrustline: boolean; } -// Classic assets and their contracts both use 7 decimals. Soroban-only -// tokens are rejected below until the picker allows them (C3). +// XLM fees, and classic assets in a path payment. const CLASSIC_DECIMALS = 7; // The changeTrust default; the backend builds with txnbuild.MaxTrustlineLimit. const MAX_TRUSTLINE_LIMIT = "922337203685.4775807"; -const toClassicAsset = (canonical: string): Asset => { +interface SwapToken { + /** Unset for a Soroban-only token. */ + classic?: Asset; + contract: string; +} + +const toSwapToken = ( + canonical: string, + networkPassphrase: string, +): SwapToken => { const asset = getAssetFromCanonical(canonical); - if (!(asset instanceof Asset)) { + if (asset instanceof Asset) { + return { classic: asset, contract: asset.contractId(networkPassphrase) }; + } + if (!StrKey.isValidContract(asset.issuer)) { throw new Error(`Swap: unsupported asset ${canonical}`); } - return asset; + return { contract: asset.issuer }; }; +const fromUnits = (value: string | bigint | number, decimals: number) => + new BigNumber(value.toString()).shiftedBy(-decimals).toFixed(); + const fromStroops = (value: string | bigint | number) => - new BigNumber(value.toString()).shiftedBy(-CLASSIC_DECIMALS).toFixed(); + fromUnits(value, CLASSIC_DECIMALS); const mismatch = (what: string) => new Error(`Swap transaction does not match the request: ${what}`); @@ -54,10 +69,13 @@ const atLeastMinOut = (amount: BigNumber, minOut: string) => { const decodeClassic = ( tx: Transaction, account: string, - from: Asset, - to: Asset, + { classic: from }: SwapToken, + { classic: to }: SwapToken, minOut: string, ): DecodedSwapTx => { + if (!from || !to) { + throw mismatch("path payment"); + } const ops = tx.operations; const payment = ops[ops.length - 1]; const changeTrust = ops.length === 2 ? ops[0] : null; @@ -113,16 +131,14 @@ const contractCalls = ( const decodeSoroban = ( tx: Transaction, account: string, - from: Asset, - to: Asset, - networkPassphrase: string, + { contract: fromContract }: SwapToken, + { contract: toContract }: SwapToken, + sourceDecimals: number, ): DecodedSwapTx => { const op = tx.operations[0] as Operation.InvokeHostFunction; if (op.func.type !== "hostFunctionTypeInvokeContract") { throw mismatch("host function"); } - const fromContract = from.contractId(networkPassphrase); - const toContract = to.contractId(networkPassphrase); let sent = new BigNumber(0); let addsTrustline = false; @@ -158,7 +174,7 @@ const decodeSoroban = ( const resourceFee = ext?.type === "sorobanData" ? ext.sorobanData.resourceFee : undefined; return { - sendAmount: fromStroops(sent.toFixed()), + sendAmount: fromUnits(sent.toFixed(), sourceDecimals), fee: fromStroops(tx.fee), ...(resourceFee !== undefined ? { resourceFee: fromStroops(resourceFee.toString()) } @@ -180,6 +196,7 @@ export const decodeSwapTx = ({ sourceAsset, destAsset, minOut, + sourceDecimals, }: { transactionXdr: string; networkPassphrase: string; @@ -188,6 +205,8 @@ export const decodeSwapTx = ({ destAsset: string; /** The floor the build was asked for; a lower classic minimum is refused. */ minOut: string; + /** The sold token's decimals: 7 for classic assets. */ + sourceDecimals: number; }): DecodedSwapTx => { const parsed = TransactionBuilder.fromXdr(transactionXdr, networkPassphrase); if (parsed instanceof FeeBumpTransaction) { @@ -200,13 +219,13 @@ export const decodeSwapTx = ({ ) { throw mismatch("source account"); } - const from = toClassicAsset(sourceAsset); - const to = toClassicAsset(destAsset); + const from = toSwapToken(sourceAsset, networkPassphrase); + const to = toSwapToken(destAsset, networkPassphrase); if ( tx.operations.length === 1 && tx.operations[0].type === "invokeHostFunction" ) { - return decodeSoroban(tx, account, from, to, networkPassphrase); + return decodeSoroban(tx, account, from, to, sourceDecimals); } return decodeClassic(tx, account, from, to, minOut); }; diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index 3f15883f88..458972b4cb 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -11,13 +11,17 @@ import { SwapQuotesResponse, SwapRoute, } from "@shared/api/types/backend-api"; +import { AssetType } from "@shared/api/types/account-balance"; import { NetworkDetails } from "@shared/constants/stellar"; import { isNativeAssetId } from "@shared/helpers/assetIdentity"; import { getCanonicalFromAsset, isCustomNetwork, + splitCanonical, } from "@shared/helpers/stellar"; +import { findAssetBalance } from "popup/helpers/balance"; import { cleanAmount } from "popup/helpers/formatters"; +import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; import { horizonGetBestPath, horizonGetBestReceivePath, @@ -98,12 +102,43 @@ export const pickSwapRoute = ( quotes: SwapQuotesResponse, ): SwapRoute | undefined => quotes.routes[0]; +const contractIssuer = (canonical: string) => { + const issuer = canonical.split(":")[1]; + return issuer && StrKey.isValidContract(issuer) ? issuer : undefined; +}; + export const toBackendAsset = (canonical: string) => { if (isNativeAssetId(canonical)) { return "XLM"; } - const issuer = canonical.split(":")[1]; - return issuer && StrKey.isValidContract(issuer) ? issuer : canonical; + return contractIssuer(canonical) ?? canonical; +}; + +/** + * A contract token's decimals come from the account's balance, else from the + * picker row it was chosen from; undefined when neither knows them. + */ +export const getSwapTokenDecimals = ({ + canonical, + balances, + picked, +}: { + canonical: string; + balances: AssetType[]; + picked?: { decimals?: number } | null; +}): number | undefined => { + const contract = canonical ? contractIssuer(canonical) : undefined; + if (!contract) { + return CLASSIC_ASSET_DECIMALS; + } + const held = findAssetBalance(balances, { + code: splitCanonical(canonical).code, + issuer: contract, + }); + if (held && "decimals" in held) { + return Number(held.decimals); + } + return picked?.decimals; }; const fromBackendAsset = (asset: string) => { @@ -151,6 +186,10 @@ export const getSwapQuote = async ( const key = getSwapQuoteKey(params); const { amount } = key; if (isCustomNetwork(networkDetails)) { + // Contract tokens route only through the backend's Soroban sources. + if (contractIssuer(sourceAsset) || contractIssuer(destAsset)) { + return { route: null, quotes: null, key }; + } const record = mode === "send" ? await horizonGetBestPath({ diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 5d3e60cc77..8c3f9370ff 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,6 +1,5 @@ { "No path found for swap.": "No path found for swap.", - "Swapping custom tokens is not supported yet.": "Swapping custom tokens is not supported yet.", "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", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index ecc983ddc1..f05df5d2e3 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,6 +1,5 @@ { "No path found for swap.": "Nenhum caminho encontrado para a troca.", - "Swapping custom tokens is not supported yet.": "A troca de tokens personalizados ainda não é suportada.", "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", From 9a1c6c13b3d3b1c4edf1690788e369e2518be759 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 12:15:02 -0500 Subject: [PATCH 02/25] fix(swap): no trustline or reserve flow for Soroban contract tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6: the trustline, its 0.5 XLM reserve and the reserve sheet are classic-only. A non-held contract destination counted as needing a trustline (two-op fee, reserve preflight, Max less 0.5 XLM). A held contract source was offered as the reserve sheet's sell token, whose receive quote is classic-only; it still has to cover the fee in XLM. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.sorobanTokens.test.tsx | 48 +++++++++++++++++++ .../SwapAmount/helpers/getSwapDerivedData.ts | 11 +++-- .../components/swap/SwapAmount/index.tsx | 4 ++ 3 files changed, 58 insertions(+), 5 deletions(-) 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 c316ed0d13..8fdd3f02c9 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 @@ -12,6 +12,7 @@ import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/us import * as UseSimulateSwapData from "popup/components/swap/SwapAmount/hooks/useSimulateSwapData"; import * as UseNetworkFees from "popup/helpers/useNetworkFees"; import * as FetchBackendV2 from "@shared/api/helpers/fetchBackendV2"; +import { getSwapDerivedData } from "popup/components/swap/SwapAmount/helpers/getSwapDerivedData"; const TOKEN_CONTRACT = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; @@ -145,4 +146,51 @@ describe("SwapAmount with Soroban contract tokens", () => { destDecimals: 6, }); }); + + it("adds no trustline into a contract token the account doesn't hold", () => { + renderSwapAmount({ destinationAsset: TOKEN }); + + expect(lastSimParams().destRequiresTrustline).toBe(false); + }); +}); + +describe("getSwapDerivedData with a contract token sold", () => { + const derive = (xlm: string) => + getSwapDerivedData({ + data: { + type: AppDataType.RESOLVED, + networkDetails: MAINNET_NETWORK_DETAILS, + icons: {}, + userBalances: { + balances: [ + { ...nativeBalance, total: new BigNumber(xlm) }, + tokenBalance, + ], + }, + tokenPrices: {}, + } as any, + asset: TOKEN, + destinationAsset: USDC, + destinationAmount: "1", + destinationTokenDetails: null, + amount: "1", + amountUsd: "0", + fee: "0.00001", + networkDetails: MAINNET_NETWORK_DETAILS, + inputType: "crypto", + isLiveQuoteLoading: false, + isSwapUnavailable: false, + destRequiresTrustline: true, + }); + + it("does not offer it to swap for the XLM reserve: receive quotes are classic-only", () => { + const derived = derive("1"); + + expect(derived.sourceIsNonXlmClassic).toBe(false); + expect(derived.canSwapForReserve).toBe(false); + }); + + it("still needs XLM for the network fee", () => { + expect(derive("1").insufficientXlmForFees).toBe(true); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index 8856baf44f..5dddc8ee35 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -9,7 +9,7 @@ import { } from "popup/helpers/formatters"; import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; import { isNativeAssetId } from "@shared/helpers/assetIdentity"; -import { getAvailableBalance } from "popup/helpers/soroban"; +import { getAvailableBalance, isContractId } from "popup/helpers/soroban"; import { findAssetBalance, getBalanceCanonicalKey, @@ -139,11 +139,13 @@ export const getSwapDerivedData = ({ // "Swap for 0.5 XLM" reserve-recovery affordance on the XlmReserveSheet. // The sell side is the current source when it's already a non-XLM // classic token; otherwise the largest held non-XLM classic balance. - const sourceIsNonXlmClassic = !!asset && !isNativeAssetId(asset); + const sourceIsNonXlm = !!asset && !isNativeAssetId(asset); + const sourceIsNonXlmClassic = + sourceIsNonXlm && !isContractId(getAssetFromCanonical(asset).issuer); // Source token Blockaid verdict (from its held balance), passed to the review // gate so a flagged sell token also warns. XLM is never scanned. - const sourceBalance = sourceIsNonXlmClassic + const sourceBalance = sourceIsNonXlm ? findAssetBalance( sendData.userBalances.balances, getAssetFromCanonical(asset), @@ -198,8 +200,7 @@ export const getSwapDerivedData = ({ recommendedFee: "0", }); const insufficientXlmForFees = - sourceIsNonXlmClassic && - new BigNumber(xlmSpendableForFees).lt(new BigNumber(fee)); + sourceIsNonXlm && new BigNumber(xlmSpendableForFees).lt(new BigNumber(fee)); const cta = getSwapCtaState({ hasSource: !!asset, diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index e103161906..bad18594c1 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -41,6 +41,7 @@ import { reRouteOnboarding } from "popup/helpers/route"; import { CLASSIC_ASSET_DECIMALS, getAvailableBalance, + isContractId, } from "popup/helpers/soroban"; import { getBalanceCanonicalKey } from "popup/helpers/balance"; import { useBlockaidOverrideState } from "popup/helpers/blockaid"; @@ -166,8 +167,11 @@ export const SwapAmount = ({ balances: heldBalances, picked: transactionData.destinationTokenDetails, }); + // Only a classic destination needs a trustline; a contract token holds its + // own balances. const destRequiresTrustline = Boolean(destinationAsset) && + !isContractId(dstAsset?.issuer) && heldBalances.length > 0 && !heldBalances.some((b) => getBalanceCanonicalKey(b) === destinationAsset); From 14d9ba4e9a3bad0ac571a6394086c912d1ddd5ca Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 12:19:44 -0500 Subject: [PATCH 03/25] feat(swap): offer Soroban contract tokens in the token picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6 / C3: the picker keeps its sources (held tokens, stellar.expert search and trending, asset lists) and stops filtering out Soroban contract tokens. - Held contract tokens appear in "Your tokens" (both pickers), with their balance at their decimals. - Search and trending keep contract records, carrying stellar.expert's decimals onto the picked token, so the build knows them. - Contract tokens are bulk-scanned with Blockaid as CODE-CONTRACT, as the Add token flow scans them. - A contract token needs no trustline. isContract now means a Soroban- only token (contract issuer); a SAC is listed in its classic form. - The "Soroban tokens aren't supported" empty state, hadSorobanMatches and the picker's now-unused isDestination prop are gone; e2e test 5 now expects the contract token's row. Co-Authored-By: Claude Opus 5.5 --- @shared/api/types/types.ts | 2 + extension/e2e-tests/swap.test.ts | 31 ++--- .../__tests__/SwapPickerSections.test.tsx | 35 +----- .../SwapAsset/SwapPickerSections/index.tsx | 28 +---- .../SwapAsset/__tests__/SwapAsset.test.tsx | 2 - .../useSwapTokenLookup.soroban.test.tsx | 113 ++++++++++++++++++ .../__tests__/useSwapTokenLookup.test.ts | 87 ++++++++++---- .../SwapAsset/hooks/useSwapTokenLookup.ts | 66 +++------- .../popup/components/swap/SwapAsset/index.tsx | 5 - .../helpers/__tests__/trendingAssets.test.ts | 33 ++++- extension/src/popup/helpers/trendingAssets.ts | 20 +++- .../__tests__/translationParity.test.ts | 1 - .../src/popup/locales/en/translation.json | 1 - .../src/popup/locales/pt/translation.json | 1 - .../__tests__/Swap.selectionType.test.tsx | 2 - .../views/__tests__/Swap.telemetry.test.tsx | 1 - 16 files changed, 260 insertions(+), 168 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index ad2d12343c..c40744f8cd 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -42,6 +42,8 @@ export interface TrendingAsset { domain: string | null; icon?: string; volume7d: number; + /** Soroban contract tokens only. */ + decimals?: number; } export interface AllowList { diff --git a/extension/e2e-tests/swap.test.ts b/extension/e2e-tests/swap.test.ts index f63367b287..d70ee2c86e 100644 --- a/extension/e2e-tests/swap.test.ts +++ b/extension/e2e-tests/swap.test.ts @@ -28,7 +28,6 @@ * - trustline-info-sheet TrustlineInfoSheet.tsx:16 * - XlmReserveSheet XlmReserveSheet/index.tsx:27 * - swap-picker-fallback-notice SwapPickerSections/index.tsx:108 - * - swap-picker-empty-soroban SwapPickerSections/index.tsx:123 * - blockaid-malicious-label WarningMessages/index.tsx:768,933 * * testids NOT yet in source (design doc used wrong names; real counterparts above): @@ -50,7 +49,7 @@ import { stubSwapQuotes, stubSwapTx, } from "./helpers/stubs"; -// Soroban contract address — searching for this should produce the Soroban empty state. +// A Soroban-only token's contract address. const SOROBAN_CONTRACT_ADDRESS = "CAZXRTOKNUQ2JQQF3NCRU7GYMDJNZ2NMQN6IGN4FCT5DWPODMPVEXSND"; @@ -445,11 +444,9 @@ test("flagged destination surfaces blockaid malicious warning at review", async }); // --------------------------------------------------------------------------- -// 5. Search: Soroban contract address → Soroban empty state -// Entering a contract address in the "Swap to" search should surface the -// Soroban-unsupported empty state copy. +// 5. Search: Soroban contract address → the contract token's row // --------------------------------------------------------------------------- -test("search with Soroban contract address shows Soroban empty state", async ({ +test("search with a Soroban contract address lists the contract token", async ({ page, extensionId, context, @@ -457,12 +454,17 @@ test("search with Soroban contract address shows Soroban empty state", async ({ test.slow(); const stubOverrides = async () => { - // Return empty results — the Soroban detection happens in the picker via - // hadSorobanMatches logic in SwapPickerSections based on the search term - // being a contract-shaped address; no search result records needed. await page.unroute("**/asset?search**"); await page.route("**/asset?search**", (route) => - route.fulfill({ json: { _embedded: { records: [] } } }), + route.fulfill({ + json: { + _embedded: { + records: [ + { asset: SOROBAN_CONTRACT_ADDRESS, code: "TKN", decimals: 18 }, + ], + }, + }, + }), ); await page.route("**/asset?sort=volume7d**", (route) => route.fulfill({ json: { _embedded: { records: [] } } }), @@ -476,18 +478,11 @@ test("search with Soroban contract address shows Soroban empty state", async ({ await expect(page.getByTestId("swap-sell-card")).toBeVisible(); await openSwapToPicker(page); - // Type a Soroban contract address — SwapPickerSections.hadSorobanMatches - // triggers when the search term looks like a contract ID (56-char Stellar G/C address) await page.getByTestId("swap-from-search").fill(SOROBAN_CONTRACT_ADDRESS); - // The Soroban empty state message (swap-picker-empty-soroban) - // Text: "Soroban contract tokens aren't supported for swaps yet." - await expect(page.getByTestId("swap-picker-empty-soroban")).toBeVisible({ + await expect(page.getByTestId("SwapTokenRow-TKN")).toBeVisible({ timeout: 15000, }); - await expect( - page.getByText(/Soroban contract tokens aren't supported/), - ).toBeVisible(); }); // --------------------------------------------------------------------------- diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index 8f1e155e21..9251280a6f 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -44,7 +44,6 @@ const emptyResult = { popular: [], verified: [], unverified: [], - hadSorobanMatches: false, isFallback: false, isNewAccount: false, }; @@ -53,7 +52,6 @@ const baseProps = { onClickAsset: jest.fn(), stellarExpertUrl: "https://stellar.expert/explorer/public", // Destination picker triggers the Soroban empty states; the source picker is covered by a dedicated test. - isDestination: true, }; describe("SwapPickerSections", () => { @@ -131,44 +129,15 @@ describe("SwapPickerSections", () => { ); }); - it("Soroban empty state shown when hadSorobanMatches", () => { + it("shows the generic empty state for a pasted contract id with no matches", () => { render( , - ); - - expect(screen.getByTestId("swap-picker-empty-soroban")).toBeInTheDocument(); - }); - - it("Soroban empty state shown when the search term is a contract id with no results", () => { - render( - , - ); - - expect(screen.getByTestId("swap-picker-empty-soroban")).toBeInTheDocument(); - expect(screen.queryByTestId("swap-picker-empty")).toBeNull(); - }); - - it("source picker: a pasted contract id with no matches shows the generic empty state, not the Soroban one", () => { - render( - , ); - // The Soroban "not supported" copy only makes sense on the swap-TO picker. - expect(screen.queryByTestId("swap-picker-empty-soroban")).toBeNull(); expect(screen.getByTestId("swap-picker-empty")).toBeInTheDocument(); }); diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx index 9718cdb1d8..d910d2bd7f 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/index.tsx @@ -9,7 +9,7 @@ import { UnverifiedTokenInfoSheet, } from "popup/components/TokenVerificationSheets"; import { AssetListRow } from "popup/components/AssetListRow"; -import { isContractId } from "popup/helpers/soroban"; +import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; import { SecurityLevel } from "popup/constants/blockaid"; import type { SwapTokenRecord } from "../hooks/useSwapTokenLookup"; import type { SwapPickerSelection } from "../index"; @@ -19,17 +19,14 @@ import "./styles.scss"; /** Which picker section a clicked row came from (used for telemetry). */ type PickerSource = "balances" | "popular" | "search"; -/** - * Builds the destination descriptor passed up to the Swap view on pick. - * `decimals` is 7 for classic Stellar assets. - */ +/** Builds the destination descriptor passed up to the Swap view on pick. */ const buildSelection = ( r: SwapTokenRecord, source: PickerSource, ): SwapPickerSelection => ({ tokenCode: r.code ?? "", requiresTrustline: r.requiresTrustline, - decimals: 7, + decimals: r.isContract ? r.decimals : CLASSIC_ASSET_DECIMALS, issuer: r.issuer || undefined, securityLevel: r.securityLevel, securityWarnings: r.securityWarnings, @@ -48,7 +45,6 @@ export interface SwapPickerSectionsResult { popular: SwapTokenRecord[]; verified: SwapTokenRecord[]; unverified: SwapTokenRecord[]; - hadSorobanMatches: boolean; isFallback: boolean; /** True when the account has no held balances (new/unfunded account). */ isNewAccount: boolean; @@ -66,11 +62,6 @@ export interface SwapPickerSectionsProps { details?: SwapPickerSelection, ) => void; stellarExpertUrl: string; - /** True on the swap-TO (destination) picker. The Soroban "not supported" - * empty state only applies to the destination; the source picker shows the - * generic "no tokens match" empty state instead (you can only swap FROM a - * token you already hold). */ - isDestination: boolean; /** Whether token discovery (Popular + search) exists on this network — true on * Mainnet/Testnet, false on custom/Futurenet. When false the discovery- * unavailable notice is suppressed: the network simply has no Popular list, so @@ -84,7 +75,6 @@ export const SwapPickerSections = ({ hiddenAssets = [], onClickAsset, stellarExpertUrl, - isDestination, isDiscoverySupported = false, }: SwapPickerSectionsProps) => { const { t } = useTranslation(); @@ -187,17 +177,7 @@ export const SwapPickerSections = ({ )} {!hasResults ? ( - isDestination && - (result.hadSorobanMatches || isContractId(searchTerm.trim())) ? ( -
- {t( - "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", - )} -
- ) : isSearching ? ( + isSearching ? (
{ unverified: [], }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }, error: null, diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx new file mode 100644 index 0000000000..4e88da06c6 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { Provider } from "react-redux"; +import { renderHook, act } from "@testing-library/react"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; + +const mockState = { + cache: { tokenLists: [] as unknown[], popularTokens: {}, icons: {} }, +}; +jest.mock("popup/App", () => ({ + store: { getState: () => mockState }, +})); +jest.mock("@shared/api/helpers/token-list", () => ({ + getCombinedAssetListData: jest.fn().mockResolvedValue([]), +})); +jest.mock("popup/helpers/searchAsset", () => ({ + searchAsset: jest.fn(), +})); +jest.mock("popup/helpers/assetList", () => ({ + splitVerifiedAssetCurrency: jest.fn(async ({ assets }) => ({ + verifiedAssets: [], + unverifiedAssets: assets, + })), +})); +jest.mock("popup/helpers/useHiddenAssets", () => ({ + readHiddenAssets: jest.fn().mockResolvedValue({}), +})); +jest.mock("popup/helpers/blockaid", () => ({ + ...jest.requireActual("popup/helpers/blockaid"), + scanAssetBulk: jest.fn(), +})); + +import { searchAsset } from "popup/helpers/searchAsset"; +import { scanAssetBulk } from "popup/helpers/blockaid"; +import { SecurityLevel } from "popup/constants/blockaid"; +import { makeDummyStore } from "popup/__testHelpers__"; +import { + useSwapTokenLookup, + resetSwapIdleCacheForTests, +} from "../useSwapTokenLookup"; + +const CONTRACT = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + +const renderLookup = () => { + const store = makeDummyStore({ settings: { assetsLists: {} } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook(() => useSwapTokenLookup(), { wrapper }); +}; + +const search = async ( + result: ReturnType["result"], + searchTerm: string, +) => { + await act(async () => { + await result.current.fetchData({ + searchTerm, + balances: [], + publicKey: "GADUMMY", + networkDetails: MAINNET_NETWORK_DETAILS, + }); + }); + return result.current.state.data!.sections.unverified; +}; + +describe("useSwapTokenLookup with Soroban contract tokens", () => { + beforeEach(() => { + (searchAsset as jest.Mock).mockResolvedValue({ + _embedded: { + records: [ + { + asset: CONTRACT, + code: "TKN", + decimals: 18, + tomlInfo: { image: "https://toml.example/tkn.png" }, + }, + ], + }, + }); + (scanAssetBulk as jest.Mock).mockResolvedValue({ + results: { [`TKN-${CONTRACT}`]: { result_type: "Malicious" } }, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + resetSwapIdleCacheForTests(); + mockState.cache.tokenLists = []; + mockState.cache.icons = {}; + }); + + it("finds one by search, with its decimals, and scans it with Blockaid", async () => { + const { result } = renderLookup(); + + const rows = await search(result, "tkn"); + + expect(scanAssetBulk).toHaveBeenCalledWith( + [`TKN-${CONTRACT}`], + MAINNET_NETWORK_DETAILS, + expect.anything(), + ); + expect(rows).toEqual([ + expect.objectContaining({ + code: "TKN", + issuer: CONTRACT, + isContract: true, + requiresTrustline: false, + decimals: 18, + securityLevel: SecurityLevel.MALICIOUS, + }), + ]); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts index ffdd83ff7e..900a05b520 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts @@ -16,6 +16,7 @@ const heldAqua = { token: { code: "AQUA", issuer: { key: "GBNZ" } }, total: "100", } as any; +const CONTRACT = "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7"; const heldXlm = { token: { type: "native", code: "XLM" }, total: "50" } as any; describe("buildSwapSections — idle (no search term)", () => { @@ -69,19 +70,55 @@ describe("buildSwapSections — idle (no search term)", () => { expect(result.sections.yourTokens[0].image).toBe("https://icons/aqua.png"); }); - it("excludes held Soroban (contract) tokens — Classic assets only", () => { + it("lists a held Soroban contract token by its contract ID, at its decimals", () => { const heldSoroban = { - token: { code: "SRBN", issuer: { key: "GSRBN" } }, - contractId: "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7", - total: "5", - decimals: 7, + token: { code: "SRBN", issuer: { key: CONTRACT } }, + contractId: CONTRACT, + total: "1500000000000000000", + decimals: 18, } as any; const result = buildSwapSections({ searchTerm: "", balances: [heldAqua, heldSoroban], networkDetails: MAINNET, }); - expect(result.sections.yourTokens.map((r) => r.code)).toEqual(["AQUA"]); + expect(result.sections.yourTokens.map((r) => r.code)).toEqual([ + "AQUA", + "SRBN", + ]); + expect(result.sections.yourTokens[1]).toMatchObject({ + issuer: CONTRACT, + canonical: `SRBN:${CONTRACT}`, + isContract: true, + requiresTrustline: false, + decimals: 18, + tokenAmount: "1.5", + }); + }); + + it("keeps Popular Soroban contract tokens the verified lists vouch for", () => { + const result = buildSwapSections({ + searchTerm: "", + balances: [], + networkDetails: MAINNET, + popular: [ + { + code: "TKN", + issuer: CONTRACT, + contract: CONTRACT, + domain: null, + volume7d: 9, + decimals: 6, + }, + ], + verifiedAssets: [{ code: "TKN", issuer: CONTRACT, domain: null }], + }); + expect(result.sections.popular[0]).toMatchObject({ + code: "TKN", + isContract: true, + requiresTrustline: false, + decimals: 6, + }); }); it("sorts Your tokens by descending fiat value", () => { @@ -108,11 +145,6 @@ describe("buildSwapSections — search term", () => { searchTerm: "usd", balances: [heldAqua], networkDetails: MAINNET, - searchResults: [ - { code: "USDC", issuer: "GUSD", domain: null }, - { code: "USDC", issuer: "GUSD", domain: null }, // duplicate -> deduped - { code: "USDT", issuer: "GUSDT", domain: null }, - ], verifiedAssets: [{ code: "USDC", issuer: "GUSD", domain: null }], unverifiedAssets: [{ code: "USDT", issuer: "GUSDT", domain: null }], }); @@ -122,26 +154,31 @@ describe("buildSwapSections — search term", () => { expect(result.sections.unverified.map((r) => r.code)).toEqual(["USDT"]); }); - it("drops Soroban contract results and sets hadSorobanMatches", () => { + it("keeps Soroban contract results, with no trustline to add", () => { + const wrap = { + code: "WRAP", + issuer: CONTRACT, + contract: CONTRACT, + domain: null, + decimals: 18, + }; const result = buildSwapSections({ searchTerm: "CAZX", balances: [], networkDetails: MAINNET, - searchResults: [ - { - code: "WRAP", - issuer: "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7", - contract: "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7", - domain: null, - }, - ], verifiedAssets: [], - unverifiedAssets: [], + unverifiedAssets: [wrap], }); - expect(result.sections.verified).toEqual([]); - expect(result.sections.unverified).toEqual([]); - expect(result.hadSorobanMatches).toBe(true); + expect(result.sections.unverified).toEqual([ + expect.objectContaining({ + code: "WRAP", + canonical: `WRAP:${CONTRACT}`, + isContract: true, + requiresTrustline: false, + decimals: 18, + }), + ]); }); }); @@ -177,7 +214,6 @@ describe("omitHiddenFromSections", () => { searchTerm: "u", balances: [heldXlm], networkDetails: MAINNET, - searchResults: [], verifiedAssets: [{ code: "USDC", issuer: "GUSD", domain: null }], unverifiedAssets: [{ code: "USDT", issuer: "GUSD", domain: null }], }); @@ -198,7 +234,6 @@ describe("omitHiddenFromSections", () => { searchTerm: "u", balances: [heldXlm], networkDetails: MAINNET, - searchResults: [], verifiedAssets: [{ code: "USDC", issuer: "GUSD", domain: null }], unverifiedAssets: [], }); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 46494ac073..e979b3162a 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -85,14 +85,12 @@ export interface SwapTokenLookupResult { unverified: SwapTokenRecord[]; }; isSearch: boolean; - hadSorobanMatches: boolean; isFallback: boolean; } export const EMPTY_RESULT: SwapTokenLookupResult = { sections: { yourTokens: [], popular: [], verified: [], unverified: [] }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }; @@ -110,12 +108,12 @@ const heldToRecord = ( maybeTokenPrices?: ApiTokenPrices | null, ): SwapTokenRecord | null => { const tokenPrices = maybeTokenPrices ?? {}; - // Classic-only: swaps run over the Classic path, so the "Your tokens" list - // shows native + classic assets only. Exclude liquidity-pool shares (no token) - // and custom Soroban contract tokens (carry a contractId). - if (!("token" in balance) || !balance.token || "contractId" in balance) { + // Liquidity-pool shares have no token to swap. + if (!("token" in balance) || !balance.token) { return null; } + const sorobanDecimals = + "contractId" in balance ? Number(balance.decimals) : undefined; const token = balance.token as { code: string; type?: string; @@ -129,7 +127,7 @@ const heldToRecord = ( // Held-token balance, fiat value and 24h delta (mirrors the account-home row). const total = new BigNumber( (balance as { total?: BigNumber.Value }).total ?? 0, - ); + ).shiftedBy(-(sorobanDecimals ?? 0)); const tokenAmount = formatAmount(total.toFixed()); const price = tokenPrices[canonical]; const fiatValue = price?.currentPrice @@ -153,8 +151,9 @@ const heldToRecord = ( fiatValue, percentChange24h, isHeld: true, - isContract: false, + isContract: sorobanDecimals !== undefined, requiresTrustline: false, + ...(sorobanDecimals !== undefined ? { decimals: sorobanDecimals } : {}), }; }; @@ -188,13 +187,15 @@ const currencyToRecord = ( const canonical = isNative ? "native" : getCanonicalFromAsset(asset.code || "", asset.issuer || ""); - const isContract = !!(asset.contract && isContractId(asset.contract)); + // Soroban-only: a classic asset's own contract (SAC) is listed in its + // classic form, which needs a trustline. + const isContract = isContractId(asset.issuer); return { ...asset, canonical, isHeld, isContract, - requiresTrustline: !isHeld && !isNative, + requiresTrustline: !isHeld && !isNative && !isContract, spotPrice: asset.price, }; }; @@ -205,9 +206,6 @@ const currencyToRecord = ( * Idle (no searchTerm): yourTokens + popular (volume7d ∩ verified, held filtered out) * Search (searchTerm): yourTokens + verified + unverified (mutually exclusive, deduped) * Fallback: yourTokens only (held-in-memory filter), popular/verified/unverified empty - * - * Classic-only: any non-SAC Soroban contract is stripped and sets hadSorobanMatches = true. - */ export const buildSwapSections = ({ searchTerm, @@ -215,7 +213,6 @@ export const buildSwapSections = ({ popular = [], verifiedAssets = [], unverifiedAssets = [], - searchResults = [], isFallback = false, icons = {}, tokenPrices, @@ -228,7 +225,6 @@ export const buildSwapSections = ({ popular?: TrendingAsset[]; verifiedAssets?: ManageAssetCurrency[]; unverifiedAssets?: ManageAssetCurrency[]; - searchResults?: ManageAssetCurrency[]; isFallback?: boolean; icons?: Record; tokenPrices?: ApiTokenPrices | null; @@ -239,24 +235,6 @@ export const buildSwapSections = ({ const heldRecords = balancesToHeldRecords({ balances, icons, tokenPrices }); const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); - // Classic-only filter: drop any record whose issuer or contract is a contract - // id — i.e. a custom Soroban token. Classic CODE-ISSUER records are kept, - // including SAC-backed assets, which stellar.expert returns in their classic - // form (a bare SAC contract id has no classic representation to swap). - // Side-effect: sets hadSorobanMatches so the picker can show the "try a - // Classic token" empty state. - let hadSorobanMatches = false; - const isClassic = (asset: ManageAssetCurrency): boolean => { - const isSorobanContract = - (asset.contract && isContractId(asset.contract)) || - (asset.issuer && isContractId(asset.issuer)); - if (isSorobanContract) { - hadSorobanMatches = true; - return false; - } - return true; - }; - if (!isSearch) { // IDLE mode const verifiedKeys = new Set( @@ -272,9 +250,9 @@ export const buildSwapSections = ({ contract: p.contract, domain: p.domain, image: p.icon, + decimals: p.decimals, }), ) - .filter(isClassic) .map((a) => currencyToRecord(a, false)) .filter( (r) => @@ -289,7 +267,6 @@ export const buildSwapSections = ({ unverified: [], }, isSearch: false, - hadSorobanMatches, isFallback, }; } @@ -306,7 +283,6 @@ export const buildSwapSections = ({ return { sections: { yourTokens, popular: [], verified: [], unverified: [] }, isSearch: true, - hadSorobanMatches: false, isFallback: true, }; } @@ -317,7 +293,6 @@ export const buildSwapSections = ({ const dedupe = (assets: ManageAssetCurrency[]): SwapTokenRecord[] => { const seen = new Set(); return assets - .filter(isClassic) .map((a) => currencyToRecord(a, false)) .filter((r) => { if (heldSearchKeys.has(r.canonical) || seen.has(r.canonical)) { @@ -328,10 +303,6 @@ export const buildSwapSections = ({ }); }; - // Scan searchResults for Soroban entries to ensure hadSorobanMatches is set - // even when the split already stripped them from verifiedAssets/unverifiedAssets. - searchResults.forEach((a) => isClassic(a)); - return { sections: { yourTokens, @@ -340,7 +311,6 @@ export const buildSwapSections = ({ unverified: dedupe(unverifiedAssets), }, isSearch: true, - hadSorobanMatches, isFallback: false, }; }; @@ -451,6 +421,7 @@ const recordFromSearchResult = ( domain: record.domain ?? null, image: record.tomlInfo?.image, price: record.price, + decimals: record.decimals, }; } return { @@ -643,7 +614,6 @@ export const useSwapTokenLookup = () => { let verifiedAssets: ManageAssetCurrency[] = []; let unverifiedAssets: ManageAssetCurrency[] = []; let popular: TrendingAsset[] = []; - let searchResults: ManageAssetCurrency[] = []; if (searchTerm.trim()) { // SEARCH path: fetch Stellar Expert results and split verified / unverified @@ -652,7 +622,7 @@ export const useSwapTokenLookup = () => { networkDetails, signal, }); - searchResults = ( + const searchResults = ( (resJson?._embedded?.records as AssetSearchRecord[]) ?? [] ).map(recordFromSearchResult); @@ -701,6 +671,7 @@ export const useSwapTokenLookup = () => { const popularAsCurrency: ManageAssetCurrency[] = popular.map((p) => ({ code: p.code, issuer: p.issuer, + contract: p.contract, domain: p.domain, })); const split = await splitVerifiedAssetCurrency({ @@ -721,7 +692,6 @@ export const useSwapTokenLookup = () => { popular, verifiedAssets, unverifiedAssets, - searchResults, }); // A newer lookup (the user typed again or cleared the box) may have @@ -729,14 +699,14 @@ export const useSwapTokenLookup = () => { // (splitVerifiedAssetCurrency), so a superseded call can still reach here // and commit its stale sections over the current render — the "Your // tokens" flash. The guard below drops it before dispatch. - // Non-held classic candidates to scan with Blockaid (mainnet only; - // testnet has no Blockaid). + // Non-held candidates to scan with Blockaid (mainnet only; testnet has + // no Blockaid). const scanCandidates = isBlockaidEnabled(networkDetails) ? [ ...payload.sections.popular, ...payload.sections.verified, ...payload.sections.unverified, - ].filter((r) => r.issuer && !isContractId(r.issuer)) + ].filter((r) => r.issuer) : []; // Decorate the given payload's scannable sections from a scan map. diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index b56e9256a0..d20ba782ea 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -240,7 +240,6 @@ export const SwapAsset = ({ const pickerResult: SwapPickerSectionsResult = lookupData ? { ...lookupData.sections, - hadSorobanMatches: lookupData.hadSorobanMatches, isFallback: lookupData.isFallback, isNewAccount: heldBalancesForNewAccount.length === 0, } @@ -249,7 +248,6 @@ export const SwapAsset = ({ popular: [], verified: [], unverified: [], - hadSorobanMatches: false, isFallback: false, isNewAccount: true, }; @@ -261,7 +259,6 @@ export const SwapAsset = ({ popular: [], verified: [], unverified: [], - hadSorobanMatches: false, isFallback: false, isNewAccount: heldBalancesForNewAccount.length === 0, }; @@ -300,7 +297,6 @@ export const SwapAsset = ({ hiddenAssets={hiddenAssets} onClickAsset={onClickAsset} stellarExpertUrl={stellarExpertUrl} - isDestination isDiscoverySupported={isDiscoverySupported} /> ) : ( @@ -310,7 +306,6 @@ export const SwapAsset = ({ hiddenAssets={hiddenAssets} onClickAsset={onClickAsset} stellarExpertUrl={stellarExpertUrl} - isDestination={false} /> )}
diff --git a/extension/src/popup/helpers/__tests__/trendingAssets.test.ts b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts index 9fe257ab50..c4abf709b0 100644 --- a/extension/src/popup/helpers/__tests__/trendingAssets.test.ts +++ b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts @@ -22,7 +22,13 @@ const TESTNET: NetworkDetails = { } as NetworkDetails; const recordsResponse = ( - records: { asset: string; volume7d: number; domain?: string }[], + records: { + asset: string; + volume7d: number; + domain?: string; + code?: string; + decimals?: number; + }[], ) => ({ json: async () => ({ _embedded: { records } }), ok: true, @@ -81,6 +87,31 @@ describe("fetchTrendingAssets", () => { expect(result[0].issuer).toBe("GBIG"); }); + it("keeps Soroban contract tokens, by contract ID and with their decimals", async () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + jest.spyOn(global, "fetch").mockResolvedValue( + recordsResponse([ + { + asset: contract, + code: "TKN", + decimals: 6, + volume7d: MIN_TRENDING_VOLUME7D + 5, + }, + ]) as unknown as Response, + ); + + const result = await fetchTrendingAssets({ networkDetails: MAINNET }); + + expect(result).toEqual([ + expect.objectContaining({ + code: "TKN", + issuer: contract, + contract, + decimals: 6, + }), + ]); + }); + it("keeps every testnet record regardless of volume (floor is a no-op)", async () => { jest.spyOn(global, "fetch").mockResolvedValue( recordsResponse([ diff --git a/extension/src/popup/helpers/trendingAssets.ts b/extension/src/popup/helpers/trendingAssets.ts index 6e43e3e138..257363e632 100644 --- a/extension/src/popup/helpers/trendingAssets.ts +++ b/extension/src/popup/helpers/trendingAssets.ts @@ -2,6 +2,7 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { TrendingAsset } from "@shared/api/types"; import { getApiStellarExpertUrl } from "popup/helpers/account"; import { isTestnet } from "helpers/stellar"; +import { isContractId } from "popup/helpers/soroban"; // Re-exported so existing `popup/helpers/trendingAssets` imports keep resolving. export type { TrendingAsset }; @@ -14,6 +15,8 @@ export const MIN_TRENDING_VOLUME7D = 70_000_000_000; interface TrendingRecord { asset: string; // "CODE-ISSUER" for classic, contract id otherwise + code?: string; + decimals?: number; volume7d?: number; domain?: string; tomlInfo?: { image?: string; code?: string }; @@ -47,16 +50,23 @@ export const fetchTrendingAssets = async ({ const applyFloor = !testnet; return records - .filter((record) => record.asset.includes("-")) // classic only; contract ids dropped here, SAC handled in the hook .map((record): TrendingAsset => { - const [code, issuer] = record.asset.split("-"); - return { - code, - issuer, + const common = { domain: record.domain ?? null, icon: record.tomlInfo?.image, volume7d: record.volume7d ?? 0, }; + if (isContractId(record.asset)) { + return { + ...common, + code: record.code || record.tomlInfo?.code || "", + issuer: record.asset, + contract: record.asset, + decimals: record.decimals, + }; + } + const [code, issuer] = record.asset.split("-"); + return { ...common, code, issuer }; }) .filter((asset) => applyFloor ? asset.volume7d >= MIN_TRENDING_VOLUME7D : true, diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 49486a7acb..3983eaee18 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -4,7 +4,6 @@ import pt from "popup/locales/pt/translation.json"; const swapKeys = [ "Quote has expired, please try again to get a new quote", "Token discovery is temporarily unavailable. You can still swap between tokens you already hold.", - "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", "No tokens match {{term}}", "Select a token", "You sell", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 8c3f9370ff..75d5b4c96c 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -605,7 +605,6 @@ "Some destination accounts on the Stellar network require a memo to identify your payment.": "Some destination accounts on the Stellar network require a memo to identify your payment.", "Some of your assets may not appear, but they are still safe on the network!": "Some of your assets may not appear, but they are still safe on the network!", "Soroban": "Soroban", - "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.": "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.", "Soroban is temporarily experiencing issues": "Soroban is temporarily experiencing issues", "SOROBAN RPC URL": "SOROBAN RPC URL", "Source": "Source", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index f05df5d2e3..207a256c4a 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -605,7 +605,6 @@ "Some destination accounts on the Stellar network require a memo to identify your payment.": "Algumas contas de destino na rede Stellar exigem um memo para identificar seu pagamento.", "Some of your assets may not appear, but they are still safe on the network!": "Alguns de seus ativos podem não aparecer, mas ainda estão seguros na rede!", "Soroban": "Soroban", - "Soroban contract tokens aren't supported for swaps yet. Try searching for a Classic token instead.": "Tokens de contrato Soroban ainda não são suportados para trocas. Tente buscar um token Classic.", "Soroban is temporarily experiencing issues": "O Soroban está temporariamente com problemas", "SOROBAN RPC URL": "URL RPC SOROBAN", "Source": "Origem", diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx index 4d00a3df44..1ac99174af 100644 --- a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -66,7 +66,6 @@ const resolvedFromState = { const emptyLookupResult = { sections: { yourTokens: [], popular: [], verified: [], unverified: [] }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }; @@ -280,7 +279,6 @@ describe("Swap selectionType wiring", () => { unverified: [], }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }, error: null, diff --git a/extension/src/popup/views/__tests__/Swap.telemetry.test.tsx b/extension/src/popup/views/__tests__/Swap.telemetry.test.tsx index fbf1c8adfe..e11c6bfa0a 100644 --- a/extension/src/popup/views/__tests__/Swap.telemetry.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.telemetry.test.tsx @@ -59,7 +59,6 @@ const resolvedFromState = { const emptyLookupResult = { sections: { yourTokens: [], popular: [], verified: [], unverified: [] }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }; From fe68b49180d178b7f321365ce5e89967e40a8c6c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 12:20:47 -0500 Subject: [PATCH 04/25] feat(swap): subtitle Soroban tokens with their shortened contract ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6 row subtitle: classic tokens keep the issuer's home domain; a Soroban-only token shows its contract ID shortened (CBI7…IHRV), even if it reports a domain, so a lookalike still looks different. Held rows keep the balance subtitle they share with the account home. The row menu's "View on stellar.expert" opens a contract token's contract page; /asset/CODE-C… is not a stellar.expert page. Co-Authored-By: Claude Opus 5.5 --- extension/e2e-tests/swap.test.ts | 3 ++ .../__tests__/SwapPickerSections.test.tsx | 43 +++++++++++++++++-- .../SwapAsset/SwapPickerSections/index.tsx | 5 ++- .../__tests__/SwapTokenMenu.test.tsx | 39 +++++++++++++++++ .../swap/SwapAsset/SwapTokenMenu/index.tsx | 11 +++-- 5 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/__tests__/SwapTokenMenu.test.tsx diff --git a/extension/e2e-tests/swap.test.ts b/extension/e2e-tests/swap.test.ts index d70ee2c86e..a4ddc7024f 100644 --- a/extension/e2e-tests/swap.test.ts +++ b/extension/e2e-tests/swap.test.ts @@ -483,6 +483,9 @@ test("search with a Soroban contract address lists the contract token", async ({ await expect(page.getByTestId("SwapTokenRow-TKN")).toBeVisible({ timeout: 15000, }); + await expect(page.getByTestId("SwapTokenRow-TKN")).toContainText( + "CAZX…XSND", + ); }); // --------------------------------------------------------------------------- diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index 9251280a6f..b77201702f 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -23,9 +23,13 @@ jest.mock("popup/components/BalanceRow", () => ({ })); jest.mock("popup/components/AssetListRow", () => ({ - AssetListRow: ({ code }: { code: string }) => ( -
- ), + AssetListRow: ({ + code, + domain, + }: { + code: string; + domain?: string | null; + }) =>
, })); const rec = (code: string, isHeld = false) => ({ @@ -51,10 +55,41 @@ const emptyResult = { const baseProps = { onClickAsset: jest.fn(), stellarExpertUrl: "https://stellar.expert/explorer/public", - // Destination picker triggers the Soroban empty states; the source picker is covered by a dedicated test. }; describe("SwapPickerSections", () => { + it("subtitles a classic token with its home domain, a contract token with its shortened contract ID", () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + render( + , + ); + + expect(screen.getByTestId("row-USDC")).toHaveAttribute( + "data-domain", + "example.org", + ); + expect(screen.getByTestId("row-TKN")).toHaveAttribute( + "data-domain", + "CBI7…IHRV", + ); + }); + it("idle: renders Your tokens then Popular sections in order", () => { render( ({ openTab: jest.fn() })); + +const EXPERT = "https://stellar.expert/explorer/public"; + +const viewOnExpert = (code: string, issuerKey: string) => { + render( + , + ); + fireEvent.click(screen.getByTestId(`SwapTokenRow-${code}-menu`)); + fireEvent.click(screen.getByTestId(`SwapTokenRow-${code}-view-expert`)); +}; + +describe("SwapTokenMenu", () => { + afterEach(() => jest.clearAllMocks()); + + it("opens a classic asset's stellar.expert page", () => { + const issuer = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + viewOnExpert("USDC", issuer); + + expect(openTab).toHaveBeenCalledWith(`${EXPERT}/asset/USDC-${issuer}`); + }); + + it("opens a contract token's stellar.expert contract page", () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + viewOnExpert("TKN", contract); + + expect(openTab).toHaveBeenCalledWith(`${EXPERT}/contract/${contract}`); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/index.tsx b/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/index.tsx index 382a1bb239..a0e1f7b9e7 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapTokenMenu/index.tsx @@ -4,6 +4,7 @@ import * as Popover from "@radix-ui/react-popover"; import { Icon, Tooltip } from "@stellar/design-system"; import { openTab } from "popup/helpers/navigate"; +import { isContractId } from "popup/helpers/soroban"; import "./styles.scss"; @@ -15,8 +16,8 @@ interface SwapTokenMenuProps { /** * The "…" overflow menu shown on the right of a non-held token row in the Swap - * destination picker: copy the issuer address, or view the asset on - * stellar.expert. + * destination picker: copy the issuer (or contract) address, or view the token + * on stellar.expert. */ export const SwapTokenMenu = ({ code, @@ -44,7 +45,11 @@ export const SwapTokenMenu = ({ }; const viewOnExpert = () => { - openTab(`${stellarExpertUrl}/asset/${code}-${issuerKey}`); + openTab( + isContractId(issuerKey) + ? `${stellarExpertUrl}/contract/${issuerKey}` + : `${stellarExpertUrl}/asset/${code}-${issuerKey}`, + ); }; return ( From 98dbc665b79adb56d5ee71fe9ef93a62142a845a Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 12:23:46 -0500 Subject: [PATCH 05/25] feat(swap): picker icons from the asset lists, then stellar.toml, cached on pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6: icons come from the user's asset lists, then the issuer's stellar.toml; the first hit is cached so the token looks the same across the wallet. Discover rows showed stellar.expert's toml image even when a list (or the wallet's icon cache) had another. Rows now take the wallet's cached icon, else the first asset list's, else the toml image stellar.expert returned with the record (its copy of the issuer's stellar.toml, so no extra Horizon + toml round trip per row). The picked token's icon is written to the Redux and background icon caches, which the amount screen, review and account home read. Rows are not cached at search time, so searches don't fill the cache with tokens nobody picked. findTokenListIcon is getIconFromTokenLists' lookup without its cache write, so the picker can run it on every row. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/getIconFromTokenList.test.ts | 41 +++++++++ @shared/api/helpers/getIconFromTokenList.ts | 89 +++++++++++-------- .../useSwapTokenLookup.abort.test.tsx | 4 +- .../useSwapTokenLookup.soroban.test.tsx | 42 ++++++++- .../SwapAsset/hooks/useSwapTokenLookup.ts | 63 ++++++++++--- extension/src/popup/views/Swap/index.tsx | 19 ++++ .../__tests__/Swap.selectionType.test.tsx | 62 ++++++++++++- 7 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 @shared/api/helpers/__tests__/getIconFromTokenList.test.ts diff --git a/@shared/api/helpers/__tests__/getIconFromTokenList.test.ts b/@shared/api/helpers/__tests__/getIconFromTokenList.test.ts new file mode 100644 index 0000000000..47e7f711b7 --- /dev/null +++ b/@shared/api/helpers/__tests__/getIconFromTokenList.test.ts @@ -0,0 +1,41 @@ +import { findTokenListIcon } from "../getIconFromTokenList"; + +const ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const CONTRACT = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + +const list = (name: string, assets: Record[]) => + ({ name, assets }) as any; + +describe("findTokenListIcon", () => { + it("takes the first list that carries the asset", () => { + expect( + findTokenListIcon({ + issuerId: ISSUER, + code: "USDC", + assetsListsData: [ + list("first", [{ code: "USDC", issuer: ISSUER, icon: "first.png" }]), + list("second", [ + { code: "USDC", issuer: ISSUER, icon: "second.png" }, + ]), + ], + }), + ).toEqual({ icon: "first.png", canonicalAsset: `USDC:${ISSUER}` }); + }); + + it("matches a contract token by its contract ID", () => { + expect( + findTokenListIcon({ + contractId: CONTRACT, + code: "TKN", + assetsListsData: [list("l", [{ contract: CONTRACT, icon: "tkn.png" }])], + }), + ).toEqual({ icon: "tkn.png", canonicalAsset: `TKN:${CONTRACT}` }); + }); + + it("finds nothing for an unlisted asset", () => { + expect( + findTokenListIcon({ issuerId: ISSUER, code: "USDC", assetsListsData: [] }) + .icon, + ).toBeUndefined(); + }); +}); diff --git a/@shared/api/helpers/getIconFromTokenList.ts b/@shared/api/helpers/getIconFromTokenList.ts index aa42c39169..c1e61b6752 100644 --- a/@shared/api/helpers/getIconFromTokenList.ts +++ b/@shared/api/helpers/getIconFromTokenList.ts @@ -1,13 +1,14 @@ -import { - AssetListReponseItem, - AssetListResponse, -} from "@shared/constants/soroban/asset-list"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; import { getCanonicalFromAsset } from "@shared/helpers/stellar"; import { sendMessageToBackground } from "./extensionMessaging"; import { SERVICE_TYPES } from "../../constants/services"; -export const getIconFromTokenLists = async ({ +/** + * The icon of the first list (in the user's priority order) that carries the + * asset, matched by contract ID or by code and issuer. + */ +export const findTokenListIcon = ({ issuerId, contractId, code, @@ -18,51 +19,61 @@ export const getIconFromTokenLists = async ({ code: string; assetsListsData: AssetListResponse[]; }) => { - let verifiedToken = {} as AssetListReponseItem; - let canonicalAsset = undefined as string | undefined; - // The lists arrive in the user's configured order, which is a priority order: - // the first list carrying the asset wins. Without the labeled break below the - // outer loop runs to completion and the LAST matching list silently overwrites - // earlier, higher-priority entries. - listLoop: for (const data of assetsListsData) { - const list = data.assets; - if (list) { - for (const record of list) { - if (contractId) { - const regex = new RegExp(contractId, "i"); - if (record.contract && record.contract.match(regex) && record.icon) { - verifiedToken = record; - canonicalAsset = getCanonicalFromAsset(code, contractId); - break listLoop; - } + for (const data of assetsListsData) { + for (const record of data.assets ?? []) { + if (contractId) { + const regex = new RegExp(contractId, "i"); + if (record.contract && record.contract.match(regex) && record.icon) { + return { + icon: record.icon, + canonicalAsset: getCanonicalFromAsset(code, contractId), + }; } + } - if ( - issuerId && - record.issuer && - record.issuer === issuerId && - record.code === code && - record.icon - ) { - verifiedToken = record; - canonicalAsset = getCanonicalFromAsset(code, issuerId); - break listLoop; - } + if ( + issuerId && + record.issuer && + record.issuer === issuerId && + record.code === code && + record.icon + ) { + return { + icon: record.icon, + canonicalAsset: getCanonicalFromAsset(code, issuerId), + }; } } } + return { icon: undefined, canonicalAsset: undefined }; +}; + +export const getIconFromTokenLists = async ({ + issuerId, + contractId, + code, + assetsListsData, +}: { + issuerId?: string; + contractId?: string; + code: string; + assetsListsData: AssetListResponse[]; +}) => { + const found = findTokenListIcon({ + issuerId, + contractId, + code, + assetsListsData, + }); - if (verifiedToken?.icon) { + if (found.icon) { await sendMessageToBackground({ activePublicKey: null, assetCanonical: `${code}:${contractId || issuerId}`, - iconUrl: verifiedToken?.icon, + iconUrl: found.icon, type: SERVICE_TYPES.CACHE_ASSET_ICON, }); } - return { - icon: verifiedToken?.icon, - canonicalAsset, - }; + return found; }; diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.abort.test.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.abort.test.tsx index 826dfa920c..876a7c02ae 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.abort.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.abort.test.tsx @@ -6,7 +6,9 @@ import { renderHook, act, waitFor } from "@testing-library/react"; // caches; stub it with empty (but well-shaped) cache slices. jest.mock("popup/App", () => ({ store: { - getState: () => ({ cache: { tokenLists: [], popularTokens: {} } }), + getState: () => ({ + cache: { tokenLists: [], popularTokens: {}, icons: {} }, + }), }, })); // No verified lists cached -> the hook fetches them; return empty so it proceeds diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx index 4e88da06c6..6ce2af855b 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx @@ -5,7 +5,11 @@ import { renderHook, act } from "@testing-library/react"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; const mockState = { - cache: { tokenLists: [] as unknown[], popularTokens: {}, icons: {} }, + cache: { + tokenLists: [] as unknown[], + popularTokens: {}, + icons: {} as Record, + }, }; jest.mock("popup/App", () => ({ store: { getState: () => mockState }, @@ -110,4 +114,40 @@ describe("useSwapTokenLookup with Soroban contract tokens", () => { }), ]); }); + + describe("row icons", () => { + const listing = (icon: string) => ({ + name: "List", + assets: [{ contract: CONTRACT, code: "TKN", icon }], + }); + + it("prefer the user's asset lists over the issuer's stellar.toml", async () => { + mockState.cache.tokenLists = [listing("https://list.example/tkn.png")]; + const { result } = renderLookup(); + + const [row] = await search(result, "tkn"); + + expect(row.image).toBe("https://list.example/tkn.png"); + }); + + it("fall back to the stellar.toml image", async () => { + const { result } = renderLookup(); + + const [row] = await search(result, "tkn"); + + expect(row.image).toBe("https://toml.example/tkn.png"); + }); + + it("use the icon the wallet already cached for the token", async () => { + mockState.cache.tokenLists = [listing("https://list.example/tkn.png")]; + mockState.cache.icons = { + [`TKN:${CONTRACT}`]: "https://cached.example/tkn.png", + }; + const { result } = renderLookup(); + + const [row] = await search(result, "tkn"); + + expect(row.image).toBe("https://cached.example/tkn.png"); + }); + }); }); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index e979b3162a..36cc0c2a08 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -11,6 +11,7 @@ import { } from "@shared/api/types"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; +import { findTokenListIcon } from "@shared/api/helpers/getIconFromTokenList"; import { AssetType } from "@shared/api/types/account-balance"; import { initialState, reducer } from "helpers/request"; @@ -45,6 +46,7 @@ import { import { settingsSelector } from "popup/ducks/settings"; import { tokensListsSelector, + iconsSelector, saveTokenLists, popularTokensSelector, savePopularTokens, @@ -348,6 +350,43 @@ export const omitHiddenFromSections = ( }; }; +/** + * Discover rows' icons, in the order of spec §6: the icon the wallet already + * cached for the token (so it looks the same everywhere), then the user's + * asset lists, then the issuer's stellar.toml image that stellar.expert + * returned with the record. + */ +export const withRowIcons = ( + result: SwapTokenLookupResult, + cachedIcons: Record, + assetsListsData: AssetListResponse[], +): SwapTokenLookupResult => { + const withIcon = (r: SwapTokenRecord): SwapTokenRecord => { + if (!r.issuer) { + return r; + } + const image = + cachedIcons[r.canonical] || + findTokenListIcon({ + issuerId: r.isContract ? undefined : r.issuer, + contractId: r.isContract ? r.issuer : r.contract, + code: r.code ?? "", + assetsListsData, + }).icon || + r.image; + return image === r.image ? r : { ...r, image }; + }; + return { + ...result, + sections: { + ...result.sections, + popular: result.sections.popular.map(withIcon), + verified: result.sections.verified.map(withIcon), + unverified: result.sections.unverified.map(withIcon), + }, + }; +}; + export const mergeScanResults = ({ rows, scanResults, @@ -683,16 +722,20 @@ export const useSwapTokenLookup = () => { verifiedAssets = split.verifiedAssets; } - let payload = buildSwapSections({ - searchTerm, - balances, - networkDetails, - icons, - tokenPrices, - popular, - verifiedAssets, - unverifiedAssets, - }); + let payload = withRowIcons( + buildSwapSections({ + searchTerm, + balances, + networkDetails, + icons, + tokenPrices, + popular, + verifiedAssets, + unverifiedAssets, + }), + iconsSelector(store.getState()), + assetsListsData, + ); // A newer lookup (the user typed again or cleared the box) may have // superseded this one. Some awaits above don't honor the AbortSignal diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index a63753fb07..68a5f1f9c6 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -30,6 +30,9 @@ import { import { navigateTo } from "popup/helpers/navigate"; import { ROUTES } from "popup/constants/routes"; import { resetSimulation } from "popup/ducks/token-payment"; +import { iconsSelector, saveIconsForBalances } from "popup/ducks/cache"; +import { sendMessageToBackground } from "@shared/api/helpers/extensionMessaging"; +import { SERVICE_TYPES } from "@shared/constants/services"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { publicKeySelector } from "popup/ducks/accountServices"; import { isAssetVisible } from "popup/helpers/settings"; @@ -256,6 +259,22 @@ export const Swap = () => { hiddenAssets={[transactionData.asset]} goBack={() => setActiveStep(STEPS.AMOUNT)} onClickAsset={(canonical, isContract, details) => { + const iconUrl = details?.iconUrl; + if ( + iconUrl && + !isNativeAssetId(canonical) && + iconsSelector(store.getState())[canonical] !== iconUrl + ) { + dispatch( + saveIconsForBalances({ icons: { [canonical]: iconUrl } }), + ); + sendMessageToBackground({ + activePublicKey: null, + assetCanonical: canonical, + iconUrl, + type: SERVICE_TYPES.CACHE_ASSET_ICON, + }).catch(() => undefined); + } dispatch(saveDestinationAsset(canonical)); dispatch(saveIsToken(isContract)); dispatch(saveDestinationTokenDetails(details ?? null)); diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx index 1ac99174af..3eca209a4f 100644 --- a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -11,7 +11,9 @@ import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; -import { Wrapper } from "popup/__testHelpers__"; +import { Wrapper, getTestStore } from "popup/__testHelpers__"; +import * as ExtensionMessaging from "@shared/api/helpers/extensionMessaging"; +import { SERVICE_TYPES } from "@shared/constants/services"; import { Swap } from "popup/views/Swap"; import { emitMetric } from "helpers/metrics"; import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; @@ -233,6 +235,64 @@ describe("Swap selectionType wiring", () => { }); }); + it("caches a picked token's icon so it looks the same across the wallet", async () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + const canonical = `TKN:${contract}`; + const icon = "https://list.example/tkn.png"; + const sendMessage = jest + .spyOn(ExtensionMessaging, "sendMessageToBackground") + .mockResolvedValue({} as any); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: { + sections: { + yourTokens: [], + popular: [ + { + canonical, + code: "TKN", + issuer: contract, + domain: null, + image: icon, + isHeld: false, + isContract: true, + requiresTrustline: false, + decimals: 18, + }, + ], + verified: [], + unverified: [], + }, + isSearch: false, + isFallback: false, + }, + error: null, + }, + } as any); + + renderSwap(); + const selectors = await screen.findAllByTestId( + "send-amount-edit-dest-asset", + ); + await act(async () => { + fireEvent.click(selectors[1]); + }); + await act(async () => { + fireEvent.click(await screen.findByTestId("SwapTokenRow-TKN-body")); + }); + + expect(getTestStore()!.getState().cache.icons[canonical]).toBe(icon); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: SERVICE_TYPES.CACHE_ASSET_ICON, + assetCanonical: canonical, + iconUrl: icon, + }), + ); + }); + it("resets the source to (+) Select when the destination picker picks the current source token", async () => { const issuer = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; const canonical = `USDC:${issuer}`; From 067c1049cd3801a84beb9bd31f87568fe03d35e1 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 12:24:29 -0500 Subject: [PATCH 06/25] test(swap): a Soroban token no source routes shows no quote Co-Authored-By: Claude Opus 5.5 --- .../__tests__/SwapAmount.liveQuote.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx index d182332ed7..dd6d46a6e2 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx @@ -220,6 +220,30 @@ describe("SwapAmount live receive-amount quote", () => { expect(cta()).toHaveTextContent("No quote available"); }); + it("shows no quote for a Soroban token no source routes, asking for its contract ID", async () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + fetchBackendV2.mockResolvedValue({ + status: 200, + body: { + data: { + routes: [], + unavailable: [ + { source: "classic", reason: "unsupported" }, + { source: "lifi", reason: "no_route" }, + ], + }, + }, + }); + renderSwapAmount({ amount: "5", destinationAsset: `TKN:${contract}` }); + + await settle(); + + expect(JSON.parse(fetchBackendV2.mock.calls[0][0].body).toAsset).toBe( + contract, + ); + expect(cta()).toHaveTextContent("No quote available"); + }); + it("treats a 400 as no quote", async () => { fetchBackendV2.mockResolvedValue({ status: 400, From c26139c0d6d9fd4684f1f4265d77cdecf1c51c33 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 12:25:25 -0500 Subject: [PATCH 07/25] style(e2e): prettier Co-Authored-By: Claude Opus 5.5 --- extension/e2e-tests/swap.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/extension/e2e-tests/swap.test.ts b/extension/e2e-tests/swap.test.ts index a4ddc7024f..61dae8f8ec 100644 --- a/extension/e2e-tests/swap.test.ts +++ b/extension/e2e-tests/swap.test.ts @@ -483,9 +483,7 @@ test("search with a Soroban contract address lists the contract token", async ({ await expect(page.getByTestId("SwapTokenRow-TKN")).toBeVisible({ timeout: 15000, }); - await expect(page.getByTestId("SwapTokenRow-TKN")).toContainText( - "CAZX…XSND", - ); + await expect(page.getByTestId("SwapTokenRow-TKN")).toContainText("CAZX…XSND"); }); // --------------------------------------------------------------------------- From 0d093eb7dfb077cc5a95466f15848ba3b4dcdb4b Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 15:03:50 -0500 Subject: [PATCH 08/25] fix(swap): read an unheld contract token's decimals on chain, or refuse User-approved decision: a contract token the account doesn't hold gets its decimals from getDecimalsForAsset (the existing on-chain read via /token-details), not from the stellar.expert row it was picked from and never a silent 7. The read runs at the start of the build; if it fails, the build stops before any quote or /swap/tx call, shows "Couldn't read this token's details", and reports to Sentry. getSwapTokenDecimals is gone; getHeldTokenDecimals only reads a held balance, and getDecimalsForAsset is the one resolver. The sell input still uses 7 for an unheld source, which has no balance to swap. New tests: on-chain decimals reach /swap/tx's minOut; a 6 -> 18 decimal build scales the amount sold and minOut to each side. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.settingsRebuild.test.tsx | 1 - .../SwapAmount.sorobanTokens.test.tsx | 13 ++-- .../useSimulateSwapData.quote.test.tsx | 70 +++++++++++++++++-- .../SwapAmount/hooks/useSimulateSwapData.tsx | 44 +++++++++--- .../components/swap/SwapAmount/index.tsx | 20 +++--- .../popup/helpers/__tests__/swapQuote.test.ts | 45 +++--------- extension/src/popup/helpers/swapQuote.ts | 36 +++------- .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + 9 files changed, 135 insertions(+), 96 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.settingsRebuild.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.settingsRebuild.test.tsx index 6d3dd5d828..4f0d3098b6 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.settingsRebuild.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.settingsRebuild.test.tsx @@ -101,7 +101,6 @@ const renderSwapAmount = (transactionData: Record) => , ); - const builtTx = (req: any) => new TransactionBuilder(new Account(req.account, "1"), { fee: "100", 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 8fdd3f02c9..169be4620f 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 @@ -124,13 +124,11 @@ describe("SwapAmount with Soroban contract tokens", () => { tokenBalance, ]); - expect(lastSimParams()).toMatchObject({ - sourceDecimals: 18, - destDecimals: 7, - }); + expect(lastSimParams()).toMatchObject({ sourceDecimals: 18 }); + expect(lastSimParams().destDecimals).toBeUndefined(); }); - it("builds with the picked row's decimals for a contract token not held", () => { + it("leaves a contract token not held to be read on chain, not the picked row's value", () => { renderSwapAmount({ destinationAsset: TOKEN, destinationTokenDetails: { @@ -141,10 +139,7 @@ describe("SwapAmount with Soroban contract tokens", () => { }, }); - expect(lastSimParams()).toMatchObject({ - sourceDecimals: 7, - destDecimals: 6, - }); + expect(lastSimParams().destDecimals).toBeUndefined(); }); it("adds no trustline into a contract token the account doesn't hold", () => { diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx index 4b1997486a..9dab1c84d8 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.quote.test.tsx @@ -23,6 +23,7 @@ import { } from "popup/ducks/transactionSubmission"; import * as Blockaid from "popup/helpers/blockaid"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; +import * as Soroban from "popup/helpers/soroban"; import { useSimulateTxData } from "../useSimulateSwapData"; @@ -696,7 +697,10 @@ describe("useSimulateTxData quote when no live quote exists", () => { }); }); - it("does not build into a contract token whose decimals are unknown", async () => { + it("reads a picked token's decimals on chain and builds with them", async () => { + const getDecimals = jest + .spyOn(Soroban, "getDecimalsForAsset") + .mockResolvedValue(6); fetchBackendV2.mockImplementation( answer(lifiRoute("XLM", TOKEN_CONTRACT, "2.345678"), { status: 200, @@ -712,12 +716,66 @@ describe("useSimulateTxData quote when no live quote exists", () => { await result.current.fetchData({ amount: "1" }); }); - expect( - fetchBackendV2.mock.calls.some(([{ path }]) => - path.startsWith("/swap/tx"), + expect(getDecimals).toHaveBeenCalledWith( + expect.objectContaining({ assetIssuer: TOKEN_CONTRACT }), + ); + // 2.345678 less 1%, down to the 6 decimals read on chain + expect(txRequest(fetchBackendV2)).toMatchObject({ minOut: "2.322221" }); + }); + + it("scales the amount sold and minOut to each side's own decimals", async () => { + const BIG_CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 5)); + fetchBackendV2.mockImplementation( + answer( + lifiRoute(TOKEN_CONTRACT, BIG_CONTRACT, "0.123456789012345678"), + { status: 200, build: sorobanBuild("1123456") }, ), - ).toBe(false); - expect(result.current.state.error).toBeTruthy(); + ); + const { result } = renderSimulate(undefined, { + sourceAsset: tokenAsset, + sourceDecimals: 6, + destAsset: getAssetFromCanonical(`BIG:${BIG_CONTRACT}`), + destDecimals: 18, + }); + + let payload: any; + await act(async () => { + payload = await result.current.fetchData({ amount: "1.123456" }); + }); + + expect(txRequest(fetchBackendV2)).toMatchObject({ + fromAsset: TOKEN_CONTRACT, + toAsset: BIG_CONTRACT, + amount: "1.123456", + minOut: "0.122222221122222221", + }); + expect(payload.decoded.sendAmount).toBe("1.123456"); + }); + + it("refuses up front, and reports, when a token's decimals can't be read", async () => { + jest + .spyOn(Soroban, "getDecimalsForAsset") + .mockRejectedValue(new Error("Unable to fetch decimals")); + fetchBackendV2.mockImplementation( + answer(lifiRoute("XLM", TOKEN_CONTRACT, "2.345678"), { + status: 200, + build: sorobanBuild("10000000"), + }), + ); + const { result } = renderSimulate(undefined, { + destAsset: tokenAsset, + destDecimals: undefined, + }); + + await act(async () => { + await result.current.fetchData({ amount: "1" }); + }); + + expect(fetchBackendV2).not.toHaveBeenCalled(); + expect(result.current.state.error).toBe( + "Couldn't read this token's details. Please try again.", + ); + expect(captureException).toHaveBeenCalled(); }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx index 0c785058c5..e74e46ba80 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -45,7 +45,10 @@ import { } from "popup/helpers/swapQuote"; import { decodeSwapTx, DecodedSwapTx } from "popup/helpers/decodeSwapTx"; import { isQuoteExpiredError } from "popup/helpers/quoteExpiry"; -import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; +import { + CLASSIC_ASSET_DECIMALS, + getDecimalsForAsset, +} from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { AppDispatch } from "popup/App"; @@ -55,6 +58,8 @@ export const ERROR_TO_DISPLAY = { NO_PATH_FOUND: "No path found for swap.", SWAPS_UNAVAILABLE: "Swaps are temporarily unavailable", NO_QUOTE: "No quote available", + TOKEN_DETAILS_UNAVAILABLE: + "Couldn't read this token's details. Please try again.", QUOTE_EXPIRED: "Quote has expired, please try again to get a new quote", }; @@ -88,8 +93,8 @@ interface SimulationParams { // the pick-time snapshot so defaulted/deep-linked destinations — which never // went through the picker — build the changeTrust op too. destRequiresTrustline: boolean; - sourceDecimals: number; - /** Unknown for a contract token the account doesn't hold and didn't pick. */ + /** From the held balance; otherwise read on chain at build time. */ + sourceDecimals?: number; destDecimals?: number; } @@ -260,7 +265,7 @@ const buildFromBackend = async ({ quotes: SwapQuotesResponse | null; key: SwapQuoteKey; allowedSlippage: string; - destDecimals?: number; + destDecimals: number; fee: string; timeout: number; onQuoteMoved: () => void; @@ -269,9 +274,6 @@ const buildFromBackend = async ({ if (!route) { throw new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND); } - if (destDecimals === undefined) { - throw new Error(`Swap: unknown decimals for ${key.destAsset}`); - } const minOut = getMinOut(allowedSlippage, route.toAmount, destDecimals); try { const { xdr } = await buildSwapTx({ @@ -368,6 +370,30 @@ function useSimulateTxData({ const payload = { transactionXdr: "" } as SimulateTxData; const { allowedSlippage, sourceAsset, destAsset } = simParams; + const readDecimals = ( + asset: SimulationParams["sourceAsset"], + held?: number, + ) => + held ?? + getDecimalsForAsset({ + assetIssuer: asset.issuer ?? null, + publicKey, + networkDetails, + }); + let sourceDecimals: number; + let destDecimals: number; + try { + [sourceDecimals, destDecimals] = await Promise.all([ + readDecimals(sourceAsset, simParams.sourceDecimals), + readDecimals(destAsset, simParams.destDecimals), + ]); + } catch (e) { + if (!isStale()) { + captureException(e); + } + throw new Error(ERROR_TO_DISPLAY.TOKEN_DETAILS_UNAVAILABLE); + } + const baseFee = new BigNumber(transactionFee || stroopToXlm(BASE_FEE)); // Reuse the quote the live "You receive" preview already surfaced (its @@ -462,7 +488,7 @@ function useSimulateTxData({ quotes, key: getSwapQuoteKey(quoteParams), allowedSlippage, - destDecimals: simParams.destDecimals, + destDecimals, fee: baseFee.toFixed(), timeout: transactionTimeout, onQuoteMoved: () => { @@ -479,7 +505,7 @@ function useSimulateTxData({ sourceAsset: quoteParams.sourceAsset, destAsset: quoteParams.destAsset, minOut, - sourceDecimals: simParams.sourceDecimals, + sourceDecimals, }); } catch (e) { if (!isStale()) { diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index bad18594c1..2085502781 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -77,7 +77,7 @@ 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 { getSwapQuote, getSwapTokenDecimals } from "popup/helpers/swapQuote"; +import { getHeldTokenDecimals, getSwapQuote } from "popup/helpers/swapQuote"; import { SwapQuotesError } from "@shared/api/helpers/getSwapQuotes"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; import { useSwapLiveQuote } from "./hooks/useSwapLiveQuote"; @@ -159,14 +159,10 @@ export const SwapAmount = ({ ? (swapAmountData.data.userBalances.unfilteredBalances ?? swapAmountData.data.userBalances.balances) : []; - const sourceDecimals = - getSwapTokenDecimals({ canonical: asset, balances: heldBalances }) ?? - CLASSIC_ASSET_DECIMALS; - const destDecimals = getSwapTokenDecimals({ - canonical: destinationAsset, - balances: heldBalances, - picked: transactionData.destinationTokenDetails, - }); + const heldSourceDecimals = getHeldTokenDecimals(asset, heldBalances); + const heldDestDecimals = getHeldTokenDecimals(destinationAsset, heldBalances); + // Input precision only: the build reads an unheld token's decimals on chain. + const sourceDecimals = heldSourceDecimals ?? CLASSIC_ASSET_DECIMALS; // Only a classic destination needs a trustline; a contract token holds its // own balances. const destRequiresTrustline = @@ -201,8 +197,8 @@ export const SwapAmount = ({ transactionTimeout, memo, destRequiresTrustline, - sourceDecimals, - destDecimals, + sourceDecimals: heldSourceDecimals, + destDecimals: heldDestDecimals, }, }); @@ -838,7 +834,7 @@ export const SwapAmount = ({ isAmountTooHigh={false} isReadOnly autoFocus={false} - cryptoDecimals={destDecimals ?? CLASSIC_ASSET_DECIMALS} + cryptoDecimals={heldDestDecimals ?? CLASSIC_ASSET_DECIMALS} onAmountChange={() => {}} onAmountUsdChange={() => {}} onToggleInputType={() => {}} diff --git a/extension/src/popup/helpers/__tests__/swapQuote.test.ts b/extension/src/popup/helpers/__tests__/swapQuote.test.ts index 3505ee1736..666b05762e 100644 --- a/extension/src/popup/helpers/__tests__/swapQuote.test.ts +++ b/extension/src/popup/helpers/__tests__/swapQuote.test.ts @@ -7,7 +7,7 @@ import { captureException } from "@sentry/browser"; import * as GetSwapQuotes from "@shared/api/helpers/getSwapQuotes"; import * as HorizonGetBestPath from "popup/helpers/horizonGetBestPath"; -import { getSwapQuote, getSwapTokenDecimals } from "../swapQuote"; +import { getHeldTokenDecimals, getSwapQuote } from "../swapQuote"; jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); @@ -382,7 +382,7 @@ describe("getSwapQuote", () => { }); }); -describe("getSwapTokenDecimals", () => { +describe("getHeldTokenDecimals", () => { const TOKEN = `TKN:${TOKEN_CONTRACT}`; const heldToken = { token: { code: "TKN", issuer: { key: TOKEN_CONTRACT } }, @@ -390,42 +390,19 @@ describe("getSwapTokenDecimals", () => { decimals: 18, } as any; - it.each([ - ["XLM", "native"], - ["a classic asset", USDC], - ])("is 7 for %s", (_name, canonical) => { - expect( - getSwapTokenDecimals({ - canonical, - balances: [], - picked: { decimals: 3 }, - }), - ).toBe(7); - }); - it("reads a held contract token's decimals from its balance", () => { - expect( - getSwapTokenDecimals({ - canonical: TOKEN, - balances: [heldToken], - picked: { decimals: 6 }, - }), - ).toBe(18); + expect(getHeldTokenDecimals(TOKEN, [heldToken])).toBe(18); }); - it("falls back to the picked row's decimals", () => { - expect( - getSwapTokenDecimals({ - canonical: TOKEN, - balances: [], - picked: { decimals: 6 }, - }), - ).toBe(6); + it("is undefined for a contract token not held", () => { + expect(getHeldTokenDecimals(TOKEN, [])).toBeUndefined(); }); - it("is unknown for a contract token neither held nor picked", () => { - expect( - getSwapTokenDecimals({ canonical: TOKEN, balances: [] }), - ).toBeUndefined(); + it.each([ + ["a classic asset", USDC], + ["XLM", "native"], + ["no token", ""], + ])("is undefined for %s", (_name, canonical) => { + expect(getHeldTokenDecimals(canonical, [heldToken])).toBeUndefined(); }); }); diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index 458972b4cb..5b47675269 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -21,7 +21,7 @@ import { } from "@shared/helpers/stellar"; import { findAssetBalance } from "popup/helpers/balance"; import { cleanAmount } from "popup/helpers/formatters"; -import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; +import { isContractId } from "popup/helpers/soroban"; import { horizonGetBestPath, horizonGetBestReceivePath, @@ -114,31 +114,17 @@ export const toBackendAsset = (canonical: string) => { return contractIssuer(canonical) ?? canonical; }; -/** - * A contract token's decimals come from the account's balance, else from the - * picker row it was chosen from; undefined when neither knows them. - */ -export const getSwapTokenDecimals = ({ - canonical, - balances, - picked, -}: { - canonical: string; - balances: AssetType[]; - picked?: { decimals?: number } | null; -}): number | undefined => { - const contract = canonical ? contractIssuer(canonical) : undefined; - if (!contract) { - return CLASSIC_ASSET_DECIMALS; - } - const held = findAssetBalance(balances, { - code: splitCanonical(canonical).code, - issuer: contract, - }); - if (held && "decimals" in held) { - return Number(held.decimals); +/** A held contract token's decimals; undefined for anything else. */ +export const getHeldTokenDecimals = ( + canonical: string, + balances: AssetType[], +): number | undefined => { + const { code, issuer } = canonical ? splitCanonical(canonical) : {}; + if (!code || !isContractId(issuer)) { + return undefined; } - return picked?.decimals; + const held = findAssetBalance(balances, { code, issuer }); + return held && "decimals" in held ? Number(held.decimals) : undefined; }; const fromBackendAsset = (asset: string) => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 75d5b4c96c..19d1adc4e9 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,4 +1,5 @@ { + "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", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 207a256c4a..76c0b1313e 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,4 +1,5 @@ { + "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", From 4907b5ab536fb450c840f96128e13a655d1ecd71 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 15:04:36 -0500 Subject: [PATCH 09/25] refactor(swap): one contract-ID check, isContractId swapQuote's contractIssuer (StrKey.isValidContract) and decodeSwapTx's own StrKey check duplicated isContractId; both now use it. Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/helpers/decodeSwapTx.ts | 4 ++-- extension/src/popup/helpers/swapQuote.ts | 19 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/extension/src/popup/helpers/decodeSwapTx.ts b/extension/src/popup/helpers/decodeSwapTx.ts index 99471d7bee..9a9a68f45c 100644 --- a/extension/src/popup/helpers/decodeSwapTx.ts +++ b/extension/src/popup/helpers/decodeSwapTx.ts @@ -5,12 +5,12 @@ import { FeeBumpTransaction, Operation, scValToNative, - StrKey, Transaction, TransactionBuilder, xdr, } from "stellar-sdk"; +import { isContractId } from "@shared/api/helpers/soroban"; import { getAssetFromCanonical } from "@shared/helpers/stellar"; /** What the review shows, read from the transaction rather than the quote. */ @@ -45,7 +45,7 @@ const toSwapToken = ( if (asset instanceof Asset) { return { classic: asset, contract: asset.contractId(networkPassphrase) }; } - if (!StrKey.isValidContract(asset.issuer)) { + if (!isContractId(asset.issuer)) { throw new Error(`Swap: unsupported asset ${canonical}`); } return { contract: asset.issuer }; diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index 5b47675269..14b4d3dc1e 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -1,6 +1,6 @@ import { captureException } from "@sentry/browser"; import BigNumber from "bignumber.js"; -import { Horizon, StrKey } from "stellar-sdk"; +import { Horizon } from "stellar-sdk"; import { getSwapQuotes, @@ -102,16 +102,16 @@ export const pickSwapRoute = ( quotes: SwapQuotesResponse, ): SwapRoute | undefined => quotes.routes[0]; -const contractIssuer = (canonical: string) => { - const issuer = canonical.split(":")[1]; - return issuer && StrKey.isValidContract(issuer) ? issuer : undefined; -}; +const isContractToken = (canonical: string) => + isContractId(splitCanonical(canonical).issuer); export const toBackendAsset = (canonical: string) => { if (isNativeAssetId(canonical)) { return "XLM"; } - return contractIssuer(canonical) ?? canonical; + return isContractToken(canonical) + ? splitCanonical(canonical).issuer + : canonical; }; /** A held contract token's decimals; undefined for anything else. */ @@ -119,11 +119,10 @@ export const getHeldTokenDecimals = ( canonical: string, balances: AssetType[], ): number | undefined => { - const { code, issuer } = canonical ? splitCanonical(canonical) : {}; - if (!code || !isContractId(issuer)) { + if (!isContractToken(canonical)) { return undefined; } - const held = findAssetBalance(balances, { code, issuer }); + const held = findAssetBalance(balances, splitCanonical(canonical)); return held && "decimals" in held ? Number(held.decimals) : undefined; }; @@ -173,7 +172,7 @@ export const getSwapQuote = async ( const { amount } = key; if (isCustomNetwork(networkDetails)) { // Contract tokens route only through the backend's Soroban sources. - if (contractIssuer(sourceAsset) || contractIssuer(destAsset)) { + if (isContractToken(sourceAsset) || isContractToken(destAsset)) { return { route: null, quotes: null, key }; } const record = From 50dfd5da99c2e69a3b6d6f4cce628bd21340e093 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 15:05:38 -0500 Subject: [PATCH 10/25] fix(swap): apply the classic send cap to classic assets only TX_SEND_MAX (922337203685.4775807) is the int64 stroop limit of a classic amount; a contract token's i128 balance has no such cap, and at 18 decimals the cap is not even the right number. Send's validator has the same cap and 7-decimal check for tokens; left alone here. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/swapAmountHelpers.test.ts | 26 +++++++++++++------ .../helpers/swapAmountValidation.ts | 4 ++- .../components/swap/SwapAmount/index.tsx | 6 ++++- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts index 8d209ba2f0..39296ce995 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts @@ -7,22 +7,32 @@ import { describe("validateSwapAmount", () => { it("returns null for a valid amount", () => { - expect(validateSwapAmount("10", 7)).toBeNull(); - expect(validateSwapAmount("0.1234567", 7)).toBeNull(); - expect(validateSwapAmount("", 7)).toBeNull(); + expect(validateSwapAmount("10", 7, true)).toBeNull(); + expect(validateSwapAmount("0.1234567", 7, true)).toBeNull(); + expect(validateSwapAmount("", 7, true)).toBeNull(); }); it("flags more decimals than the token has", () => { - expect(validateSwapAmount("0.12345678", 7)).toBe(AMOUNT_ERROR.DEC_MAX); - expect(validateSwapAmount("0.1234567", 6)).toBe(AMOUNT_ERROR.DEC_MAX); + expect(validateSwapAmount("0.12345678", 7, true)).toBe( + AMOUNT_ERROR.DEC_MAX, + ); + expect(validateSwapAmount("0.1234567", 6, false)).toBe( + AMOUNT_ERROR.DEC_MAX, + ); }); it("accepts up to an 18-decimal token's precision", () => { - expect(validateSwapAmount("0.123456789012345678", 18)).toBeNull(); + expect(validateSwapAmount("0.123456789012345678", 18, false)).toBeNull(); + }); + + it("flags a classic amount above the max send amount", () => { + expect(validateSwapAmount("1000000000000", 7, true)).toBe( + AMOUNT_ERROR.SEND_MAX, + ); }); - it("flags an amount above the max send amount", () => { - expect(validateSwapAmount("1000000000000", 7)).toBe(AMOUNT_ERROR.SEND_MAX); + it("does not cap a contract token at the classic max", () => { + expect(validateSwapAmount("1000000000000", 18, false)).toBeNull(); }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts b/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts index ca408802e6..1c4dfc6c0a 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts @@ -11,12 +11,14 @@ import { AMOUNT_ERROR } from "helpers/transaction"; export const validateSwapAmount = ( rawAmount: string, decimals: number, + // TX_SEND_MAX is the classic int64 limit; a contract token's i128 has none. + isClassic: boolean, ): string | null => { const val = cleanAmount(rawAmount); if (val.indexOf(".") !== -1 && val.split(".")[1].length > decimals) { return AMOUNT_ERROR.DEC_MAX; } - if (new BigNumber(val).gt(new BigNumber(TX_SEND_MAX))) { + if (isClassic && new BigNumber(val).gt(new BigNumber(TX_SEND_MAX))) { return AMOUNT_ERROR.SEND_MAX; } return null; diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 2085502781..a62f60b16f 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -299,7 +299,11 @@ export const SwapAmount = ({ }; const validate = (values: { amount: string }) => { - const error = validateSwapAmount(values.amount, sourceDecimals); + const error = validateSwapAmount( + values.amount, + sourceDecimals, + !isContractId(srcAsset?.issuer), + ); return error ? { amount: error } : {}; }; From 805e704299e6aa774ba2fcb5bebc2bd0630a6f9c Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 15:06:07 -0500 Subject: [PATCH 11/25] revert(swap): don't write a picked token's icon to the shared cache User-approved decision: the picker shows icons through its lookup (the wallet's icon cache, then asset lists, then the toml image) but never writes the shared icon cache. Drops the pick-time write from 98dbc665b; the lookup and the shared icon pipeline are unchanged. Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/views/Swap/index.tsx | 19 ------ .../__tests__/Swap.selectionType.test.tsx | 62 +------------------ 2 files changed, 1 insertion(+), 80 deletions(-) diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index 68a5f1f9c6..a63753fb07 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -30,9 +30,6 @@ import { import { navigateTo } from "popup/helpers/navigate"; import { ROUTES } from "popup/constants/routes"; import { resetSimulation } from "popup/ducks/token-payment"; -import { iconsSelector, saveIconsForBalances } from "popup/ducks/cache"; -import { sendMessageToBackground } from "@shared/api/helpers/extensionMessaging"; -import { SERVICE_TYPES } from "@shared/constants/services"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { publicKeySelector } from "popup/ducks/accountServices"; import { isAssetVisible } from "popup/helpers/settings"; @@ -259,22 +256,6 @@ export const Swap = () => { hiddenAssets={[transactionData.asset]} goBack={() => setActiveStep(STEPS.AMOUNT)} onClickAsset={(canonical, isContract, details) => { - const iconUrl = details?.iconUrl; - if ( - iconUrl && - !isNativeAssetId(canonical) && - iconsSelector(store.getState())[canonical] !== iconUrl - ) { - dispatch( - saveIconsForBalances({ icons: { [canonical]: iconUrl } }), - ); - sendMessageToBackground({ - activePublicKey: null, - assetCanonical: canonical, - iconUrl, - type: SERVICE_TYPES.CACHE_ASSET_ICON, - }).catch(() => undefined); - } dispatch(saveDestinationAsset(canonical)); dispatch(saveIsToken(isContract)); dispatch(saveDestinationTokenDetails(details ?? null)); diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx index 3eca209a4f..1ac99174af 100644 --- a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -11,9 +11,7 @@ import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; -import { Wrapper, getTestStore } from "popup/__testHelpers__"; -import * as ExtensionMessaging from "@shared/api/helpers/extensionMessaging"; -import { SERVICE_TYPES } from "@shared/constants/services"; +import { Wrapper } from "popup/__testHelpers__"; import { Swap } from "popup/views/Swap"; import { emitMetric } from "helpers/metrics"; import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; @@ -235,64 +233,6 @@ describe("Swap selectionType wiring", () => { }); }); - it("caches a picked token's icon so it looks the same across the wallet", async () => { - const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; - const canonical = `TKN:${contract}`; - const icon = "https://list.example/tkn.png"; - const sendMessage = jest - .spyOn(ExtensionMessaging, "sendMessageToBackground") - .mockResolvedValue({} as any); - jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ - fetchData: jest.fn().mockResolvedValue(undefined), - state: { - state: RequestState.SUCCESS, - data: { - sections: { - yourTokens: [], - popular: [ - { - canonical, - code: "TKN", - issuer: contract, - domain: null, - image: icon, - isHeld: false, - isContract: true, - requiresTrustline: false, - decimals: 18, - }, - ], - verified: [], - unverified: [], - }, - isSearch: false, - isFallback: false, - }, - error: null, - }, - } as any); - - renderSwap(); - const selectors = await screen.findAllByTestId( - "send-amount-edit-dest-asset", - ); - await act(async () => { - fireEvent.click(selectors[1]); - }); - await act(async () => { - fireEvent.click(await screen.findByTestId("SwapTokenRow-TKN-body")); - }); - - expect(getTestStore()!.getState().cache.icons[canonical]).toBe(icon); - expect(sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: SERVICE_TYPES.CACHE_ASSET_ICON, - assetCanonical: canonical, - iconUrl: icon, - }), - ); - }); - it("resets the source to (+) Select when the destination picker picks the current source token", async () => { const issuer = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; const canonical = `USDC:${issuer}`; From 14be1783e5d76e7cbb3a96a1cf72a950949e4b83 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 15:07:14 -0500 Subject: [PATCH 12/25] feat(swap): off mainnet, leave unheld Soroban tokens out of discovery User-approved decision: only mainnet has a source (LI.FI) that routes contract tokens, so on testnet and other networks search and Popular show no contract token the account doesn't hold. Held ones stay in Your tokens. buildSwapSections now reads the networkDetails it was passed. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/useSwapTokenLookup.test.ts | 36 +++++++++++++++++++ .../SwapAsset/hooks/useSwapTokenLookup.ts | 8 +++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts index 900a05b520..0caf7ef0f9 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts @@ -182,6 +182,42 @@ describe("buildSwapSections — search term", () => { }); }); +describe("buildSwapSections — off mainnet", () => { + const TESTNET = { + network: "TESTNET", + networkPassphrase: "Test SDF Network ; September 2015", + } as NetworkDetails; + const wrap = { code: "WRAP", issuer: CONTRACT, domain: null, decimals: 7 }; + const heldWrap = { + token: { code: "WRAP", issuer: { key: CONTRACT } }, + contractId: CONTRACT, + total: "10000000", + decimals: 7, + } as any; + + it("leaves unheld contract tokens out of search and Popular, keeping held ones", () => { + const search = buildSwapSections({ + searchTerm: "wrap", + balances: [heldWrap], + networkDetails: TESTNET, + verifiedAssets: [{ code: "USDC", issuer: "GUSD", domain: null }], + unverifiedAssets: [{ ...wrap, code: "WRAP2" }], + }); + const idle = buildSwapSections({ + searchTerm: "", + balances: [], + networkDetails: TESTNET, + popular: [{ ...wrap, contract: CONTRACT, volume7d: 0 }], + verifiedAssets: [wrap], + }); + + expect(search.sections.yourTokens.map((r) => r.code)).toEqual(["WRAP"]); + expect(search.sections.verified.map((r) => r.code)).toEqual(["USDC"]); + expect(search.sections.unverified).toEqual([]); + expect(idle.sections.popular).toEqual([]); + }); +}); + describe("omitHiddenFromSections", () => { // A hidden token is left out of `balances`, so it would otherwise look new const hidden = { "USDC:GUSD": "hidden" } as const; diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 36cc0c2a08..1fff3b10a4 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -212,6 +212,7 @@ const currencyToRecord = ( export const buildSwapSections = ({ searchTerm, balances, + networkDetails, popular = [], verifiedAssets = [], unverifiedAssets = [], @@ -221,8 +222,6 @@ export const buildSwapSections = ({ }: { searchTerm: string; balances: AssetType[]; - // Accepted for call-site symmetry with the lookup context; the record filter - // keys purely on contract-id shape and does not use the network value. networkDetails: NetworkDetails; popular?: TrendingAsset[]; verifiedAssets?: ManageAssetCurrency[]; @@ -236,6 +235,9 @@ export const buildSwapSections = ({ const heldRecords = balancesToHeldRecords({ balances, icons, tokenPrices }); const heldCanonicals = new Set(heldRecords.map((r) => r.canonical)); + // Only mainnet has a source (LI.FI) that routes contract tokens. + const isDiscoverable = (r: SwapTokenRecord) => + !r.isContract || isMainnet(networkDetails); if (!isSearch) { // IDLE mode @@ -256,6 +258,7 @@ export const buildSwapSections = ({ }), ) .map((a) => currencyToRecord(a, false)) + .filter(isDiscoverable) .filter( (r) => !heldCanonicals.has(r.canonical) && verifiedKeys.has(r.canonical), @@ -296,6 +299,7 @@ export const buildSwapSections = ({ const seen = new Set(); return assets .map((a) => currencyToRecord(a, false)) + .filter(isDiscoverable) .filter((r) => { if (heldSearchKeys.has(r.canonical) || seen.has(r.canonical)) { return false; From 1f3685dbdf526349fb13a9107cbcfcd4a1829bd4 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 15:08:05 -0500 Subject: [PATCH 13/25] feat(swap): offer Swap on a Soroban token's details screen User-approved decision: the asset details screen's Swap button now shows for contract tokens too (not for liquidity-pool shares), opening Swap with the token as the source, now that the swap flow handles them. Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/components/__tests__/AssetDetail.test.tsx | 4 ++-- extension/src/popup/components/account/AssetDetail/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/components/__tests__/AssetDetail.test.tsx b/extension/src/popup/components/__tests__/AssetDetail.test.tsx index 5df1df8f59..e2e753be8d 100644 --- a/extension/src/popup/components/__tests__/AssetDetail.test.tsx +++ b/extension/src/popup/components/__tests__/AssetDetail.test.tsx @@ -925,7 +925,7 @@ describe("AssetDetail", () => { expect(screen.getByTestId("asset-detail-swap-button")).toBeVisible(); }); - it("should not show swap button for Soroban asset", async () => { + it("shows the swap button for a Soroban asset", async () => { const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; const props = { @@ -967,7 +967,7 @@ describe("AssetDetail", () => { ); await waitFor(() => screen.getByTestId("AssetDetail")); - expect(screen.queryByTestId("asset-detail-swap-button")).toBeNull(); + expect(screen.getByTestId("asset-detail-swap-button")).toBeVisible(); expect(screen.getByTestId("asset-detail-send-button")).toBeVisible(); }); diff --git a/extension/src/popup/components/account/AssetDetail/index.tsx b/extension/src/popup/components/account/AssetDetail/index.tsx index 496e335645..a393c5ca65 100644 --- a/extension/src/popup/components/account/AssetDetail/index.tsx +++ b/extension/src/popup/components/account/AssetDetail/index.tsx @@ -399,7 +399,7 @@ export const AssetDetail = ({ selectedBalance.contractId, networkDetails.networkPassphrase, ); - const isShowingSwap = !isSorobanAsset && !isLpShare; + const isShowingSwap = !isLpShare; const isShowingSend = hasBalance; // See the `AssetDetail__price` div below for why this falls back to spot. From 97ae1c53a5291962f43be4855563fea84eb01818 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 16:04:40 -0500 Subject: [PATCH 14/25] fix(swap): a SAC held by contract ID still needs its trustline A SAC added by contract ID without a trustline is held Soroban-shaped (injectLocalTokenBalances), named after its classic asset. Picked as the destination it counted as a contract token, so no reserve preflight and no 0.5 XLM held back from Max before review. Checked against freighter-backend-v2 #178 (lifi.go toClassic/BuildTx): the backend detects a SAC destination from its contract instance and lets LI.FI's trust(account) through; decodeSwapTx reads it as addsTrustline, so the build and the review's trustline banner already worked. Only the pre-review reserve check was missing. An unheld SAC returned by stellar.expert as a bare contract record would still be missed; stellar.expert returns SACs in classic form. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.sorobanTokens.test.tsx | 44 +++++++++++++++++++ .../components/swap/SwapAmount/index.tsx | 30 +++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) 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 169be4620f..9251e1363c 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 @@ -1,6 +1,7 @@ import React from "react"; import { render } from "@testing-library/react"; import BigNumber from "bignumber.js"; +import { Asset, Networks } from "stellar-sdk"; import { RequestState } from "constants/request"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; @@ -147,6 +148,49 @@ describe("SwapAmount with Soroban contract tokens", () => { expect(lastSimParams().destRequiresTrustline).toBe(false); }); + + // A SAC added by contract ID without a trustline is held Soroban-shaped, + // named after its classic asset (injectLocalTokenBalances). + describe("a SAC held by its contract ID", () => { + const USDC_ISSUER = USDC.split(":")[1]; + const sacId = new Asset("USDC", USDC_ISSUER).contractId(Networks.PUBLIC); + const sacBalance = { + token: { code: "USDC", issuer: { key: sacId } }, + contractId: sacId, + total: new BigNumber(0), + decimals: 7, + name: USDC, + symbol: "USDC", + }; + + it("needs the classic asset's trustline", () => { + renderSwapAmount({ destinationAsset: `USDC:${sacId}` }, [ + nativeBalance, + sacBalance, + ]); + + expect(lastSimParams().destRequiresTrustline).toBe(true); + }); + + it("needs none when the trustline exists", () => { + const usdcTrustline = { + token: { + type: "credit_alphanum4", + code: "USDC", + issuer: { key: USDC_ISSUER }, + }, + total: new BigNumber(1), + available: new BigNumber(1), + }; + renderSwapAmount({ destinationAsset: `USDC:${sacId}` }, [ + nativeBalance, + sacBalance, + usdcTrustline, + ]); + + expect(lastSimParams().destRequiresTrustline).toBe(false); + }); + }); }); describe("getSwapDerivedData with a contract token sold", () => { diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index a62f60b16f..d403a7ee98 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -31,6 +31,7 @@ import { } from "popup/helpers/formatters"; import { useGetSwapAmountData } from "./hooks/useGetSwapAmountData"; import { getAssetFromCanonical } from "helpers/stellar"; +import { splitCanonical } from "@shared/helpers/stellar"; import { isNativeAssetId } from "@shared/helpers/assetIdentity"; import { RequestState } from "constants/request"; import { Loading } from "popup/components/Loading"; @@ -42,8 +43,12 @@ import { CLASSIC_ASSET_DECIMALS, getAvailableBalance, isContractId, + isSacContract, } from "popup/helpers/soroban"; -import { getBalanceCanonicalKey } from "popup/helpers/balance"; +import { + findAssetBalance, + getBalanceCanonicalKey, +} from "popup/helpers/balance"; import { useBlockaidOverrideState } from "popup/helpers/blockaid"; import { AppDispatch } from "popup/App"; import { emitMetric, emitScreenViewed } from "helpers/metrics"; @@ -164,12 +169,29 @@ export const SwapAmount = ({ // Input precision only: the build reads an unheld token's decimals on chain. const sourceDecimals = heldSourceDecimals ?? CLASSIC_ASSET_DECIMALS; // Only a classic destination needs a trustline; a contract token holds its - // own balances. + // own balances, except a SAC (held by contract ID, named CODE:ISSUER), which + // needs its classic asset's. + const dstContractBalance = + dstAsset && isContractId(dstAsset.issuer) + ? findAssetBalance(heldBalances, dstAsset) + : undefined; + const dstClassicCanonical = + dstContractBalance && + "name" in dstContractBalance && + isSacContract( + dstContractBalance.name, + dstContractBalance.contractId, + networkDetails.networkPassphrase, + ) + ? dstContractBalance.name + : destinationAsset; const destRequiresTrustline = Boolean(destinationAsset) && - !isContractId(dstAsset?.issuer) && + !isContractId(splitCanonical(dstClassicCanonical).issuer) && heldBalances.length > 0 && - !heldBalances.some((b) => getBalanceCanonicalKey(b) === destinationAsset); + !heldBalances.some( + (b) => getBalanceCanonicalKey(b) === dstClassicCanonical, + ); // 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 From e295b03692aedb16249a4d9599b0a6b2b4b03c33 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 16:05:49 -0500 Subject: [PATCH 15/25] fix(swap): reset isToken when swap-for-reserve replaces the source "Swap for 0.5 XLM" swaps the best classic balance into XLM, but left isToken as a Soroban source had set it, so the review showed empty Soroban fee rows and asked RPC whether the classic source's SAC supports muxed addresses. Both sides are classic after it. The direction toggle also writes isToken false when the new source is a held Soroban token; the swap review reads Soroban fees from the build's resource fee, so that one shows nothing wrong and is left as is. Co-Authored-By: Claude Opus 5.5 --- .../SwapAmount.reserveQuote.test.tsx | 39 ++++++++++++++++++- .../components/swap/SwapAmount/index.tsx | 2 + 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx index 8839902d2b..dc33ded543 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.reserveQuote.test.tsx @@ -106,7 +106,9 @@ describe("SwapAmount swap-for-reserve quote", () => { jest.clearAllMocks(); }); - const renderAndSwapForReserve = async () => { + const renderAndSwapForReserve = async ( + transactionData: Record = {}, + ) => { render( { requiresTrustline: true, decimals: 7, }, + ...transactionData, }, }, } as any @@ -175,6 +178,40 @@ describe("SwapAmount swap-for-reserve quote", () => { ); }); + it("marks the swap classic again when it replaces a Soroban source", async () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { + ...swapData, + userBalances: { + balances: [ + ...swapData.userBalances.balances, + { + token: { code: "TKN", issuer: { key: contract } }, + contractId: contract, + total: new BigNumber("50000000"), + decimals: 7, + name: "Token", + symbol: "TKN", + }, + ], + }, + }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); + + await renderAndSwapForReserve({ asset: `TKN:${contract}`, isToken: true }); + + const { transactionData } = + getTestStore()!.getState().transactionSubmission; + expect(transactionData.asset).toBe(USDC); + expect(transactionData.isToken).toBe(false); + }); + it("reports a classic outage instead of treating it as no route", async () => { fetchBackendV2.mockImplementation(async () => ({ status: 200, diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index d403a7ee98..31b97487bb 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -502,6 +502,8 @@ export const SwapAmount = ({ // The receive side becomes XLM — a held token, so no trustline is needed. dispatch(saveDestinationAsset("native")); dispatch(saveDestinationTokenDetails(null)); + // Both sides are classic from here on. + dispatch(saveIsToken(false)); if (!sourceIsNonXlmClassic) { // Switching the sell side to a different token: reset the amount, since From 5c084cb31534b427541b6368c37b436b440083cc Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 9 Nov 2026 16:06:41 -0500 Subject: [PATCH 16/25] fix(swap): name a contract token with no code by its contract ID stellar.expert can return a SEP-41 contract with no code metadata; the picker showed it as a blank row and carried an empty code to the amount card and review. Its code falls back to the shortened contract ID, as its subtitle already shows. The Add token flow's mapStellarExpertRecord has the same empty fallback; outside swap, left as is. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/useSwapTokenLookup.soroban.test.tsx | 11 +++++++++++ .../swap/SwapAsset/hooks/useSwapTokenLookup.ts | 10 ++++++++-- .../helpers/__tests__/trendingAssets.test.ts | 15 +++++++++++++++ extension/src/popup/helpers/trendingAssets.ts | 7 +++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx index 6ce2af855b..3f75c844b0 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx @@ -115,6 +115,17 @@ describe("useSwapTokenLookup with Soroban contract tokens", () => { ]); }); + it("names a found token with no code by its shortened contract ID", async () => { + (searchAsset as jest.Mock).mockResolvedValue({ + _embedded: { records: [{ asset: CONTRACT, decimals: 7 }] }, + }); + const { result } = renderLookup(); + + const [row] = await search(result, "cbi7"); + + expect(row.code).toBe("CBI7…IHRV"); + }); + describe("row icons", () => { const listing = (icon: string) => ({ name: "List", diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index 1fff3b10a4..ad05ffd889 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -16,7 +16,12 @@ import { AssetType } from "@shared/api/types/account-balance"; import { initialState, reducer } from "helpers/request"; import { RequestState } from "constants/request"; -import { isMainnet, isTestnet, getCanonicalFromAsset } from "helpers/stellar"; +import { + isMainnet, + isTestnet, + getCanonicalFromAsset, + truncateString, +} from "helpers/stellar"; import { isNativeAssetPair, isNativeBalance, @@ -458,7 +463,8 @@ const recordFromSearchResult = ( ): ManageAssetCurrency => { if (isContractId(record.asset)) { return { - code: record.code || record.tomlInfo?.code || "", + code: + record.code || record.tomlInfo?.code || truncateString(record.asset), issuer: record.asset, contract: record.asset, domain: record.domain ?? null, diff --git a/extension/src/popup/helpers/__tests__/trendingAssets.test.ts b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts index c4abf709b0..30f36a9d8c 100644 --- a/extension/src/popup/helpers/__tests__/trendingAssets.test.ts +++ b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts @@ -112,6 +112,21 @@ describe("fetchTrendingAssets", () => { ]); }); + it("names a contract token with no code by its shortened contract ID", async () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + jest + .spyOn(global, "fetch") + .mockResolvedValue( + recordsResponse([ + { asset: contract, decimals: 7, volume7d: MIN_TRENDING_VOLUME7D + 5 }, + ]) as unknown as Response, + ); + + const [token] = await fetchTrendingAssets({ networkDetails: MAINNET }); + + expect(token.code).toBe("CBI7…IHRV"); + }); + it("keeps every testnet record regardless of volume (floor is a no-op)", async () => { jest.spyOn(global, "fetch").mockResolvedValue( recordsResponse([ diff --git a/extension/src/popup/helpers/trendingAssets.ts b/extension/src/popup/helpers/trendingAssets.ts index 257363e632..fb52ea2a31 100644 --- a/extension/src/popup/helpers/trendingAssets.ts +++ b/extension/src/popup/helpers/trendingAssets.ts @@ -1,7 +1,7 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { TrendingAsset } from "@shared/api/types"; import { getApiStellarExpertUrl } from "popup/helpers/account"; -import { isTestnet } from "helpers/stellar"; +import { isTestnet, truncateString } from "helpers/stellar"; import { isContractId } from "popup/helpers/soroban"; // Re-exported so existing `popup/helpers/trendingAssets` imports keep resolving. @@ -59,7 +59,10 @@ export const fetchTrendingAssets = async ({ if (isContractId(record.asset)) { return { ...common, - code: record.code || record.tomlInfo?.code || "", + code: + record.code || + record.tomlInfo?.code || + truncateString(record.asset), issuer: record.asset, contract: record.asset, decimals: record.decimals, From edab953a564bf06cc37187acda779d49c74f79bd Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 19:39:37 -0500 Subject: [PATCH 17/25] fix(swap): a contract token is one identity, its contract ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A held contract token is keyed by its on-chain symbol (SYM:C…) and a search or Popular row for the same contract by stellar.expert's code (ABC:C…), so every identity check saw two tokens: the row listed as new next to the held balance, a hidden token resurfaced in search, picking the held contract as destination did not reset the source, and the direction toggle treated the held destination as non-held. Mobile keys Soroban tokens on the contract ID (canonicalId); the extension now does the same through swapIdentityKey at each comparison, leaving stored canonicals and the wire format untouched. Sites changed (gate 1 grep of canonical-vs-canonical comparisons under src/popup/components/swap and src/popup/views/Swap): buildSwapSections held set, verified set, Popular filter and search dedupe; omitHiddenFromSections; SwapPickerSections hiddenAssets; withRowIcons cached-icon lookup; getSwapDerivedData.destinationIsNonHeld; the Swap view's two same-token resets. Not changed: tokenPrices / icons maps read by canonical (storage keys, not identity), getHeldTokenDecimals and findAssetBalance (already by contract). Search dedupe now excludes every held identity from the discover sections, not only the held rows matching the term, so a held contract never lists as new under a code the account does not use for it. Found by /code-review on #3073. Co-Authored-By: Claude Fable 5.1 --- .../SwapAmount.sorobanTokens.test.tsx | 29 ++++++++++ .../SwapAmount/helpers/getSwapDerivedData.ts | 10 +++- .../__tests__/SwapPickerSections.test.tsx | 26 +++++++++ .../SwapAsset/SwapPickerSections/index.tsx | 11 ++-- .../__tests__/useSwapTokenLookup.test.ts | 55 +++++++++++++++++++ .../SwapAsset/hooks/useSwapTokenLookup.ts | 43 ++++++++++----- extension/src/popup/helpers/swapQuote.ts | 8 +++ extension/src/popup/views/Swap/index.tsx | 11 +++- 8 files changed, 171 insertions(+), 22 deletions(-) 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 5d72babb45..9abd809716 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 @@ -194,6 +194,35 @@ describe("SwapAmount with Soroban contract tokens", () => { }); }); +describe("getSwapDerivedData destination identity", () => { + it("knows a held contract token by its contract, whatever code the pick carried", () => { + const derived = getSwapDerivedData({ + data: { + type: AppDataType.RESOLVED, + networkDetails: MAINNET_NETWORK_DETAILS, + icons: {}, + userBalances: { balances: [nativeBalance, tokenBalance] }, + tokenPrices: {}, + } as any, + asset: "native", + // stellar.expert's code for the contract the account holds as TKN. + destinationAsset: `ABC:${TOKEN_CONTRACT}`, + destinationAmount: "1", + destinationTokenDetails: null, + amount: "1", + amountUsd: "0", + fee: "0.00001", + networkDetails: MAINNET_NETWORK_DETAILS, + inputType: "crypto", + publicKey: "G123", + swapQuoteKey: null, + destRequiresTrustline: false, + }); + + expect(derived.destinationIsNonHeld).toBe(false); + }); +}); + describe("getSwapDerivedData with a contract token sold", () => { const derive = (xlm: string) => getSwapDerivedData({ diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index e403487dd1..d0a145ac4e 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -10,6 +10,7 @@ import { import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; import { isNativeAssetId } from "@shared/helpers/assetIdentity"; import { getAvailableBalance, isContractId } from "popup/helpers/soroban"; +import { swapIdentityKey } from "popup/helpers/swapQuote"; import { findAssetBalance, getBalanceCanonicalKey, @@ -100,11 +101,14 @@ export const getSwapDerivedData = ({ // A non-held destination token can never become the source (we only swap // held/classic assets), so the direction toggle handles it specially. Detect // it by its absence from the account balances. - const heldCanonicals = new Set( - sendData.userBalances.balances.map((b) => getBalanceCanonicalKey(b)), + const heldKeys = new Set( + sendData.userBalances.balances.map((b) => + swapIdentityKey(getBalanceCanonicalKey(b)), + ), ); const destinationIsNonHeld = - Boolean(destinationAsset) && !heldCanonicals.has(destinationAsset); + Boolean(destinationAsset) && + !heldKeys.has(swapIdentityKey(destinationAsset)); const prices = sendData.tokenPrices; const assetPrice = prices[asset] && prices[asset].currentPrice; // Prefer the live backend price; fall back to the stellar.expert spot price diff --git a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index b77201702f..dba949a52b 100644 --- a/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx @@ -234,6 +234,32 @@ describe("SwapPickerSections", () => { expect(screen.getByTestId("row-DOGET")).toBeInTheDocument(); }); + it("excludes a hidden contract token by its contract, whatever code the row carries", () => { + const contract = "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7"; + render( + , + ); + + expect(screen.queryByTestId("row-ABC")).toBeNull(); + expect(screen.getByTestId("row-DOGET")).toBeInTheDocument(); + }); + it("divides Your tokens rows like Home's list, with no line after the last", () => { render(
) : isDestination ? ( - + <> + {pickError && ( +
+ +
+ )} + + ) : ( Date: Wed, 11 Nov 2026 19:46:34 -0500 Subject: [PATCH 19/25] fix(swap): look a pasted contract ID up through the backend The picker sent every search term to stellar.expert, so a contract token it does not index, or any term during a stellar.expert outage, came back "No tokens match" and could not be picked, though quoting and building would handle it. Mobile reads a pasted contract ID through token-details (handleContractLookup); the extension now does the same (Piyal's call, 2026-11-11), reusing the Add-token lookup's pieces: the backend's token details, isSacContractExecutable for a SAC, which is listed as its classic asset so the trustline logic applies, and the native row for XLM's own contract. getTokenDetails gains throwOnFailure, as mobile's did, so a failed read can be told apart from "not a token": a 400 is an answer (null), any other failure throws into the lookup's existing catch, which serves the cached result or the held-only fallback with its "discovery unavailable" notice, the same path a failed stellar.expert search takes. Other callers keep the old swallow-and-null behaviour. Found by /code-review and the drift review on #3073. Co-Authored-By: Claude Fable 5.1 --- @shared/api/internal.ts | 10 ++ .../useSwapTokenLookup.soroban.test.tsx | 92 +++++++++++++++++++ .../SwapAsset/hooks/useSwapTokenLookup.ts | 72 +++++++++++++-- 3 files changed, 164 insertions(+), 10 deletions(-) diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 9ac546bf10..877d919876 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -1474,12 +1474,15 @@ export const getTokenDetails = async ({ networkDetails, shouldFetchBalance, signal, + throwOnFailure = false, }: { contractId: string; publicKey: string; networkDetails: NetworkDetails; shouldFetchBalance?: boolean; signal?: AbortSignal; + /** Rethrow a failed request, so it can be told apart from "not a token". */ + throwOnFailure?: boolean; }): Promise<{ name: string; decimals: number; @@ -1538,6 +1541,10 @@ export const getTokenDetails = async ({ { signal }, ); const data = await response.json(); + if (response.status === 400) { + // Not a token contract: an answer, not a failure. + return null; + } if (!response.ok) { throw new Error(data); } @@ -1553,6 +1560,9 @@ export const getTokenDetails = async ({ error, )} - ${contractId} - ${networkDetails.network}`, ); + if (throwOnFailure) { + throw error; + } return null; } }; diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx index 3f75c844b0..64cac4177d 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx @@ -33,7 +33,19 @@ jest.mock("popup/helpers/blockaid", () => ({ ...jest.requireActual("popup/helpers/blockaid"), scanAssetBulk: jest.fn(), })); +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); +jest.mock("@shared/api/internal", () => ({ + ...jest.requireActual("@shared/api/internal"), + getTokenDetails: jest.fn(), +})); +jest.mock("@shared/helpers/soroban/token", () => ({ + ...jest.requireActual("@shared/helpers/soroban/token"), + isSacContractExecutable: jest.fn().mockResolvedValue(false), +})); +import { captureException } from "@sentry/browser"; +import { getTokenDetails } from "@shared/api/internal"; +import { isSacContractExecutable } from "@shared/helpers/soroban/token"; import { searchAsset } from "popup/helpers/searchAsset"; import { scanAssetBulk } from "popup/helpers/blockaid"; import { SecurityLevel } from "popup/constants/blockaid"; @@ -162,3 +174,83 @@ describe("useSwapTokenLookup with Soroban contract tokens", () => { }); }); }); + +describe("useSwapTokenLookup with a pasted contract ID", () => { + const USDC_ISSUER = + "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + + beforeEach(() => { + (scanAssetBulk as jest.Mock).mockResolvedValue({ results: {} }); + }); + afterEach(() => { + jest.clearAllMocks(); + resetSwapIdleCacheForTests(); + }); + + it("lists the token the backend describes, whether or not stellar.expert indexes it", async () => { + (getTokenDetails as jest.Mock).mockResolvedValue({ + name: "Token", + symbol: "TKN", + decimals: 18, + }); + const { result } = renderLookup(); + + const rows = await search(result, CONTRACT); + + expect(searchAsset).not.toHaveBeenCalled(); + expect(getTokenDetails).toHaveBeenCalledWith( + expect.objectContaining({ contractId: CONTRACT, publicKey: "GADUMMY" }), + ); + expect(rows).toEqual([ + expect.objectContaining({ + code: "TKN", + issuer: CONTRACT, + canonical: `TKN:${CONTRACT}`, + isContract: true, + decimals: 18, + }), + ]); + }); + + it("lists a SAC's id as its classic asset, which needs the trustline", async () => { + (getTokenDetails as jest.Mock).mockResolvedValue({ + name: `USDC:${USDC_ISSUER}`, + symbol: "USDC", + decimals: 7, + }); + (isSacContractExecutable as jest.Mock).mockResolvedValue(true); + const { result } = renderLookup(); + + const rows = await search(result, CONTRACT); + + expect(rows).toEqual([ + expect.objectContaining({ + code: "USDC", + issuer: USDC_ISSUER, + canonical: `USDC:${USDC_ISSUER}`, + isContract: false, + requiresTrustline: true, + }), + ]); + }); + + it("shows no match when the id is not a token", async () => { + (getTokenDetails as jest.Mock).mockResolvedValue(null); + const { result } = renderLookup(); + + const rows = await search(result, CONTRACT); + + expect(rows).toEqual([]); + expect(result.current.state.data!.isFallback).toBe(false); + }); + + it("falls back to held tokens, as a failed search does, when the lookup fails", async () => { + (getTokenDetails as jest.Mock).mockRejectedValue(new Error("502")); + const { result } = renderLookup(); + + await search(result, CONTRACT); + + expect(result.current.state.data!.isFallback).toBe(true); + expect(captureException).toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index d71b43eea1..b746f89360 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -28,7 +28,10 @@ import { } from "@shared/helpers/assetIdentity"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; import { BlockaidWarning, SecurityLevel } from "popup/constants/blockaid"; -import { searchAsset } from "popup/helpers/searchAsset"; +import { buildNativeAssetRow, searchAsset } from "popup/helpers/searchAsset"; +import { getTokenDetails } from "@shared/api/internal"; +import { isSacContractExecutable } from "@shared/helpers/soroban/token"; +import { isNativeContract } from "@shared/helpers/assetIdentity"; import { getPersistedPopularTokens, setPersistedPopularTokens, @@ -475,6 +478,48 @@ interface AssetSearchRecord { }; } +/** + * The one row a pasted contract ID resolves to: the token the backend + * describes, or a SAC as its classic asset (which needs the trustline). + * Empty when the contract is not a token; throws when the read fails. + */ +const lookupContractRow = async ( + contractId: string, + publicKey: string, + networkDetails: NetworkDetails, + signal: AbortSignal, +): Promise => { + if (isNativeContract(contractId, networkDetails.networkPassphrase)) { + return [buildNativeAssetRow(networkDetails)]; + } + const details = await getTokenDetails({ + contractId, + publicKey, + networkDetails, + signal, + throwOnFailure: true, + }); + if (!details) { + return []; + } + const isSac = await isSacContractExecutable( + contractId, + networkDetails, + signal, + ); + const issuer = isSac ? details.name.split(":")[1] || "" : contractId; + return [ + { + code: details.symbol, + issuer, + contract: isSac ? undefined : contractId, + domain: "", + name: details.name, + decimals: details.decimals, + }, + ]; +}; + const recordFromSearchResult = ( record: AssetSearchRecord, ): ManageAssetCurrency => { @@ -682,15 +727,22 @@ export const useSwapTokenLookup = () => { let popular: TrendingAsset[] = []; if (searchTerm.trim()) { - // SEARCH path: fetch Stellar Expert results and split verified / unverified - const resJson = await searchAsset({ - asset: searchTerm.trim(), - networkDetails, - signal, - }); - const searchResults = ( - (resJson?._embedded?.records as AssetSearchRecord[]) ?? [] - ).map(recordFromSearchResult); + // SEARCH path. A pasted contract ID is read from the backend, so any + // token contract resolves, indexed by stellar.expert or not; a failed + // read throws into the fallback below, unlike "not a token" (null). + // Anything else is a stellar.expert search. + const term = searchTerm.trim(); + const searchResults = isContractId(term) + ? await lookupContractRow(term, publicKey, networkDetails, signal) + : ( + (( + await searchAsset({ + asset: term, + networkDetails, + signal, + }) + )?._embedded?.records as AssetSearchRecord[]) ?? [] + ).map(recordFromSearchResult); const split = await splitVerifiedAssetCurrency({ networkDetails, From 44e8feeb32239d624fffc79635607c16f6f917fe Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 19:50:15 -0500 Subject: [PATCH 20/25] fix(swap): list a held contract token searched by another code edab953a5 excluded every held identity from the search results. A held contract searched by stellar.expert's code (not its balance's symbol) then vanished: the held row did not match the term, and the search row was dropped as held. Mobile dedupes search rows only against the held rows the term matched and flags the rest held by contract ID; the extension now does the same. Popular still excludes every held token, as mobile's popularTokens does. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/useSwapTokenLookup.test.ts | 20 ++++++++++++++++++- .../SwapAsset/hooks/useSwapTokenLookup.ts | 16 +++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts index a598abfdcf..3f1d26b39f 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts @@ -197,7 +197,9 @@ describe("buildSwapSections — a contract token is one identity", () => { domain: null, }; - it("dedupes a search row for a held contract, whatever code it carries", () => { + it("lists a held contract searched by another code, as held", () => { + // The held row's symbol doesn't match the term, so the search row is the + // only way to the token; it is kept and flagged held by its contract. const result = buildSwapSections({ searchTerm: "abc", balances: [heldSoroban], @@ -206,6 +208,22 @@ describe("buildSwapSections — a contract token is one identity", () => { unverifiedAssets: [], }); + expect(result.sections.yourTokens).toEqual([]); + expect(result.sections.verified).toEqual([ + expect.objectContaining({ code: "ABC", isHeld: true }), + ]); + }); + + it("dedupes a search row against the held row that matches the term", () => { + const result = buildSwapSections({ + searchTerm: "sym", + balances: [heldSoroban], + networkDetails: MAINNET, + verifiedAssets: [{ ...searchRow, code: "SYM" }, searchRow], + unverifiedAssets: [], + }); + + expect(result.sections.yourTokens.map((r) => r.code)).toEqual(["SYM"]); expect(result.sections.verified).toEqual([]); }); diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts index b746f89360..7dc20ecf6a 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -303,16 +303,24 @@ export const buildSwapSections = ({ }; } - // Dedupe by identity; a held token belongs to Your tokens only, under - // whichever code the search knows it by. + const heldSearchKeys = new Set( + yourTokens.map((r) => swapIdentityKey(r.canonical)), + ); + + // Dedupe by identity against the held rows the term matched. A held + // contract searched by a code its balance doesn't carry is only reachable + // through its search row, which lists flagged as held. const dedupe = (assets: ManageAssetCurrency[]): SwapTokenRecord[] => { const seen = new Set(); return assets - .map((a) => currencyToRecord(a, false)) + .map((a) => { + const { canonical } = currencyToRecord(a, false); + return currencyToRecord(a, heldKeys.has(swapIdentityKey(canonical))); + }) .filter(isDiscoverable) .filter((r) => { const key = swapIdentityKey(r.canonical); - if (heldKeys.has(key) || seen.has(key)) { + if (heldSearchKeys.has(key) || seen.has(key)) { return false; } seen.add(key); From 0912ca08f742cfb97e228503ffeaf0ee49069d7f Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 19:54:38 -0500 Subject: [PATCH 21/25] fix(swap): the direction toggle sets isToken from the token it sells The toggle cleared isToken unconditionally. With C3 a held contract token can sit on either side, and the review reads isToken against srcAsset (getContractIdFromTransactionData) for its contract memo and muxed-address checks, so toggling a held contract token into the sell slot left the review treating it as classic. isToken now follows the token that lands in the sell slot; an empty slot (a non-held destination that could not move) reads as classic. Every saveIsToken in the swap flow, and which side it derives from: - SwapAmount reserve prefill: false; the new source is a classic non-XLM token (correct). - SwapAmount direction toggle: now the new source (this commit). - Swap view source pick: the picked source's isContract (correct). - Swap view destination pick: the picked DESTINATION's isContract, which is the other side from what the review reads; left as is here. - Swap view deep-link source: false only when the source param is XLM; a classic or contract source param leaves the previous value; left as is here. Copilot thread on #3073. Co-Authored-By: Claude Fable 5.1 --- .../SwapAmount.directionToggle.test.tsx | 48 ++++++++++++++++++- .../components/swap/SwapAmount/index.tsx | 20 +++++--- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx index 0237e7693c..5537ab05a2 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.directionToggle.test.tsx @@ -4,7 +4,7 @@ import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; -import { Wrapper } from "popup/__testHelpers__"; +import { Wrapper, getTestStore } from "popup/__testHelpers__"; 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"; @@ -25,6 +25,19 @@ const usdcBalance = { available: new BigNumber("50"), }; +const TOKEN_CONTRACT = + "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; +const TOKEN_CANONICAL = `TKN:${TOKEN_CONTRACT}`; +const tokenBalance = { + token: { code: "TKN", issuer: { key: TOKEN_CONTRACT } }, + contractId: TOKEN_CONTRACT, + total: new BigNumber("5000000000000000000"), + available: new BigNumber("5000000000000000000"), + decimals: 18, + name: "Token", + symbol: "TKN", +}; + const makeSwapData = (balances: unknown[]) => ({ type: AppDataType.RESOLVED, applicationState: "MNEMONIC_PHRASE_CONFIRMED", @@ -40,6 +53,7 @@ const renderAmount = (transactionData: Record) => state={ { transactionSubmission: { + soroswapTokens: [], transactionData: { asset: "native", amount: "5", @@ -144,4 +158,36 @@ describe("SwapAmount direction toggle", () => { expect(within(receive).getByText("XLM")).toBeInTheDocument(); expect(within(sell).queryByText("USDC")).toBeNull(); }); + + // isToken drives the review's contract memo/muxed check against the SOURCE + // (getContractIdFromTransactionData reads srcAsset), so it follows the + // token that lands in the sell slot. + const isToken = () => + getTestStore()!.getState().transactionSubmission.transactionData.isToken; + + it("sets isToken when a held contract token moves into the sell slot", async () => { + mockAmountData([nativeBalance, tokenBalance]); + renderAmount({ destinationAsset: TOKEN_CANONICAL, isToken: false }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Swap direction")); + }); + + expect(isToken()).toBe(true); + }); + + it("clears isToken when a classic asset moves into the sell slot", async () => { + mockAmountData([nativeBalance, tokenBalance]); + renderAmount({ + asset: TOKEN_CANONICAL, + destinationAsset: "native", + isToken: true, + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Swap direction")); + }); + + expect(isToken()).toBe(false); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 44626bcea6..d950b5af64 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -824,15 +824,21 @@ export const SwapAmount = ({ // A non-held destination can't become the source, so reset // it to "(+) Select" instead of moving it into the sell // slot; otherwise swap the two positions normally. - dispatch( - saveAsset(destinationIsNonHeld ? "" : destinationAsset), - ); + const nextSrc = destinationIsNonHeld + ? "" + : destinationAsset; + dispatch(saveAsset(nextSrc)); dispatch(saveDestinationAsset(prevSrc)); - // The new destination (old source) and new source are both - // held/classic or empty — neither carries trustline/contract - // metadata. + // The new destination (old source) is held, so it carries + // no pick-time details. dispatch(saveDestinationTokenDetails(null)); - dispatch(saveIsToken(false)); + // isToken is the sell-side token's contract-ness (the + // review reads it against srcAsset). + dispatch( + saveIsToken( + isContractId(splitCanonical(nextSrc).issuer), + ), + ); // The amount was denominated in the old source token; reset // it whenever the source token changes. dispatch(saveAmount(DEFAULT_AMOUNT)); From d7bca3708f2f594b094059ea11e066f911f18ddb Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 20:21:05 -0500 Subject: [PATCH 22/25] fix(swap): a held contract picked under another code is the balance handleDestinationPick decided "held" from the Your tokens rows, which in search mode are only the rows the term matched. A held SYM:C1 found through its ABC:C1 search row (listed as held since 44e8feeb3) then got a pick-time decimals read and was stored as ABC:C1. The pick now looks the row's contract up against every held balance, the set Popular filters against, and a held match is picked as the balance: its canonical, symbol and decimals, no read. Mobile's picker takes the same rule. Medium review on the C3 fix round. Co-Authored-By: Claude Opus 5.5 --- .../SwapAsset/__tests__/SwapAsset.test.tsx | 83 +++++++++++++++++++ .../popup/components/swap/SwapAsset/index.tsx | 29 ++++++- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx index 728694392c..0f1ecb2f5d 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -374,3 +374,86 @@ describe("SwapAsset destination: a contract token the account doesn't hold", () expect(onClickAsset).toHaveBeenCalledTimes(1); }); }); + +describe("SwapAsset destination: a held contract token found by another code", () => { + const C1 = "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7"; + const heldSoroban = { + token: { code: "SYM", issuer: { key: C1 } }, + contractId: C1, + total: "1000000000000000000", + decimals: 18, + }; + + afterEach(() => jest.restoreAllMocks()); + + it("picks the held balance, without reading its decimals", async () => { + const getDecimals = jest.spyOn(Soroban, "getDecimalsForAsset"); + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: { + ...resolvedFromState, + data: { + ...resolvedFromState.data, + balances: { balances: [heldSoroban], icons: {} }, + }, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: { + sections: { + // "abc" doesn't match the held row's symbol. + yourTokens: [], + popular: [], + verified: [ + { + canonical: `ABC:${C1}`, + code: "ABC", + issuer: C1, + contract: C1, + domain: null, + isHeld: true, + isContract: true, + requiresTrustline: false, + }, + ], + unverified: [], + }, + isSearch: true, + isFallback: false, + }, + error: null, + }, + } as any); + const onClickAsset = jest.fn(); + render( + + + , + ); + + fireEvent.change(screen.getByTestId("swap-from-search"), { + target: { value: "abc" }, + }); + fireEvent.click(await screen.findByTestId("SwapTokenRow-ABC-body")); + + await waitFor(() => expect(onClickAsset).toHaveBeenCalledTimes(1)); + const [canonical, isContract, details] = onClickAsset.mock.calls[0]; + expect(canonical).toBe(`SYM:${C1}`); + expect(isContract).toBe(true); + expect(details).toMatchObject({ + tokenCode: "SYM", + decimals: 18, + requiresTrustline: false, + }); + expect(getDecimals).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index 810ed0f3ac..2f45b1299b 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -271,8 +271,14 @@ export const SwapAsset = ({ // they are read before the pick applies; stellar.expert's copy comes from // the issuer's toml and is not trusted for that. A held token's come from // its balance. - const heldKeys = new Set( - pickerResult.yourTokens.map((r) => swapIdentityKey(r.canonical)), + // Every held balance, not only the rows the search term matched: a held + // contract found under another code is picked as the balance. + const heldByKey = new Map( + balancesToHeldRecords({ + balances: heldBalancesForNewAccount, + icons, + tokenPrices, + }).map((r) => [swapIdentityKey(r.canonical), r]), ); const handleDestinationPick = ( canonical: string, @@ -284,9 +290,24 @@ export const SwapAsset = ({ } // Every pick supersedes a pending read, whether or not it needs one. pickIdRef.current += 1; - if (!isContract || heldKeys.has(swapIdentityKey(canonical))) { + const held = isContract + ? heldByKey.get(swapIdentityKey(canonical)) + : undefined; + if (!isContract || held) { pendingPickRef.current = null; - onClickAsset(canonical, isContract, details); + onClickAsset( + held ? held.canonical : canonical, + isContract, + held && details + ? { + ...details, + tokenCode: held.code ?? details.tokenCode, + issuer: held.issuer, + decimals: held.decimals, + requiresTrustline: false, + } + : details, + ); return; } const pickId = pickIdRef.current; From c9954b5ab62fb576128af53a14f89a86b1d29140 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 20:22:14 -0500 Subject: [PATCH 23/25] fix(swap): the destination pick leaves isToken to the source isToken describes the source: the review reads it against srcAsset for its contract memo and muxed-address checks (see 0912ca08f). The destination pick set it from the destination, so a contract source with a classic destination picked was reviewed as classic, and a contract destination flipped it on for a classic source. The pick no longer writes it; when it clears the source (same-token reset) it clears isToken with it. Medium review on the C3 fix round. Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/views/Swap/index.tsx | 6 +- .../__tests__/Swap.selectionType.test.tsx | 103 +++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index d6f3a71a5a..63ee9b900c 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -256,9 +256,10 @@ export const Swap = () => { selectionType="destination" hiddenAssets={[transactionData.asset]} goBack={() => setActiveStep(STEPS.AMOUNT)} - onClickAsset={(canonical, isContract, details) => { + onClickAsset={(canonical, _isContract, details) => { dispatch(saveDestinationAsset(canonical)); - dispatch(saveIsToken(isContract)); + // isToken describes the source (the review reads it against + // srcAsset), so a destination pick leaves it alone. dispatch(saveDestinationTokenDetails(details ?? null)); // Can't swap a token for itself: if it matches the current // source, reset the source to "(+) Select". @@ -267,6 +268,7 @@ export const Swap = () => { swapIdentityKey(transactionData.asset) ) { dispatch(saveAsset("")); + dispatch(saveIsToken(false)); dispatch(saveAmount("0")); dispatch(saveAmountUsd("0.00")); } diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx index 1ac99174af..010e72026f 100644 --- a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx @@ -11,7 +11,8 @@ import BigNumber from "bignumber.js"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; -import { Wrapper } from "popup/__testHelpers__"; +import { Wrapper, getTestStore } from "popup/__testHelpers__"; +import { saveIsToken } from "popup/ducks/transactionSubmission"; import { Swap } from "popup/views/Swap"; import { emitMetric } from "helpers/metrics"; import * as UseGetSwapAmountData from "popup/components/swap/SwapAmount/hooks/useGetSwapAmountData"; @@ -366,4 +367,104 @@ describe("Swap selectionType wiring", () => { within(screen.getByTestId("swap-receive-card")).getByText("Select"), ).toBeInTheDocument(); }); + + describe("isToken follows the source, not the destination picked", () => { + const CONTRACT = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + const TOKEN = `TKN:${CONTRACT}`; + const issuer = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; + const USDC = `USDC:${issuer}`; + const tokenBalance = { + token: { code: "TKN", issuer: { key: CONTRACT } }, + contractId: CONTRACT, + total: new BigNumber("5000000000000000000"), + available: new BigNumber("5000000000000000000"), + decimals: 18, + }; + const usdcBalance = { + token: { code: "USDC", issuer: { key: issuer } }, + total: new BigNumber("100"), + available: new BigNumber("100"), + }; + const heldRow = (canonical: string, code: string, isContract: boolean) => ({ + canonical, + code, + issuer: canonical.split(":")[1], + domain: null, + image: "", + isHeld: true, + isContract, + requiresTrustline: false, + decimals: isContract ? 18 : undefined, + tokenAmount: "1", + fiatValue: null, + percentChange24h: null, + }); + const isToken = () => + getTestStore()!.getState().transactionSubmission.transactionData.isToken; + + const pickDestination = async (code: string) => { + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: { + ...resolvedFromState, + data: { + ...resolvedFromState.data, + balances: { balances: [tokenBalance, usdcBalance], icons: {} }, + filteredBalances: [tokenBalance, usdcBalance], + }, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: { + ...emptyLookupResult, + sections: { + ...emptyLookupResult.sections, + yourTokens: [ + heldRow(TOKEN, "TKN", true), + heldRow(USDC, "USDC", false), + ], + }, + }, + error: null, + }, + } as any); + // A contract source, as the source picker leaves it. + renderSwap({}, [`/swap?source_asset=${TOKEN}`]); + const selectors = await screen.findAllByTestId( + "send-amount-edit-dest-asset", + ); + act(() => { + getTestStore()!.dispatch(saveIsToken(true)); + }); + await act(async () => { + fireEvent.click(selectors[1]); + }); + const row = await screen.findByTestId(`SwapTokenRow-${code}`); + await act(async () => { + fireEvent.click(row); + }); + await waitFor(() => + expect(screen.getByTestId("swap-sell-card")).toBeInTheDocument(), + ); + }; + + it("keeps isToken for a contract source when a classic destination is picked", async () => { + await pickDestination("USDC"); + + expect(isToken()).toBe(true); + }); + + it("clears isToken when picking the source's own token clears the source", async () => { + await pickDestination("TKN"); + + expect( + within(screen.getByTestId("swap-sell-card")).getByText("Select"), + ).toBeInTheDocument(); + expect(isToken()).toBe(false); + }); + }); }); From ce84bdb73c2dfd7c61ad07e234a7b78554c394c3 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 20:22:48 -0500 Subject: [PATCH 24/25] fix(swap): a plain-text 400 from token-details is "not a token" getTokenDetails parsed the body before checking for the 400 that means "not a token", so the backend's plain-text 400 ("Soroban has been disabled on pubnet") threw a SyntaxError, which throwOnFailure turns into a failed lookup. The status is now checked first, matching mobile's axios path, which never parses a 400's body. Medium review on the C3 fix round. Co-Authored-By: Claude Opus 5.5 --- @shared/api/__tests__/internal.test.ts | 22 ++++++++++++++++++++++ @shared/api/internal.ts | 5 +++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/@shared/api/__tests__/internal.test.ts b/@shared/api/__tests__/internal.test.ts index b1c9c241c3..f8a1075154 100644 --- a/@shared/api/__tests__/internal.test.ts +++ b/@shared/api/__tests__/internal.test.ts @@ -805,4 +805,26 @@ describe("internalApi", () => { }); }); }); + + describe("getTokenDetails", () => { + afterEach(() => jest.restoreAllMocks()); + + it("treats a plain-text 400 as not a token, even when asked to throw", async () => { + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.reject(new SyntaxError("Unexpected token S")), + } as unknown as Response); + + await expect( + internalApi.getTokenDetails({ + contractId: + "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV", + publicKey: "G123", + networkDetails: MAINNET_NETWORK_DETAILS, + throwOnFailure: true, + }), + ).resolves.toBeNull(); + }); + }); }); diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 877d919876..bbaf697b7a 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -1540,11 +1540,12 @@ export const getTokenDetails = async ({ }${shouldFetchBalance ? "&should_fetch_balance=true" : ""}`, { signal }, ); - const data = await response.json(); + // Not a token contract: an answer, not a failure. Checked before the + // body is parsed, since a 400 can be plain text. if (response.status === 400) { - // Not a token contract: an answer, not a failure. return null; } + const data = await response.json(); if (!response.ok) { throw new Error(data); } From f98bf5cc73aa54de0d0422ea333181d5a492bbed Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 11 Nov 2026 20:23:32 -0500 Subject: [PATCH 25/25] fix(swap): the pick-time decimals read uses the active account's key The read took the public key from the picker's account data, which can still be loading when Popular rows are already on screen; a tap then sent an empty key and the backend answered 400. It now reads the active account's key from Redux (publicKeySelector), as the rest of the swap flow does. Medium review on the C3 fix round. Co-Authored-By: Claude Opus 5.5 --- .../SwapAsset/__tests__/SwapAsset.test.tsx | 72 +++++++++++++++++++ .../popup/components/swap/SwapAsset/index.tsx | 4 +- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx index 0f1ecb2f5d..15bab68c2a 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -457,3 +457,75 @@ describe("SwapAsset destination: a held contract token found by another code", ( expect(getDecimals).not.toHaveBeenCalled(); }); }); + +describe("SwapAsset destination: a pick before the account data resolves", () => { + const CONTRACT = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + + afterEach(() => jest.restoreAllMocks()); + + it("reads the decimals with the active account's key", async () => { + const getDecimals = jest + .spyOn(Soroban, "getDecimalsForAsset") + .mockResolvedValue(18); + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: { state: RequestState.LOADING, data: null, error: null }, + fetchData: jest.fn().mockResolvedValue(undefined), + filterBalances: jest.fn(), + } as any); + jest.spyOn(UseSwapTokenLookup, "useSwapTokenLookup").mockReturnValue({ + fetchData: jest.fn().mockResolvedValue(undefined), + state: { + state: RequestState.SUCCESS, + data: { + sections: { + yourTokens: [], + popular: [ + { + canonical: `TKN:${CONTRACT}`, + code: "TKN", + issuer: CONTRACT, + contract: CONTRACT, + domain: null, + isHeld: false, + isContract: true, + requiresTrustline: false, + }, + ], + verified: [], + unverified: [], + }, + isSearch: false, + isFallback: false, + }, + error: null, + }, + } as any); + const onClickAsset = jest.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByTestId("SwapTokenRow-TKN-body")); + + await waitFor(() => expect(onClickAsset).toHaveBeenCalledTimes(1)); + expect(getDecimals).toHaveBeenCalledWith( + expect.objectContaining({ publicKey: "GACCOUNT" }), + ); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAsset/index.tsx b/extension/src/popup/components/swap/SwapAsset/index.tsx index 2f45b1299b..7d3b4c1272 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -18,6 +18,7 @@ import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; import { getStellarExpertUrl } from "popup/helpers/account"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { publicKeySelector } from "popup/ducks/accountServices"; import type { DestinationTokenDetails } from "popup/ducks/transactionSubmission"; import { getDecimalsForAsset } from "popup/helpers/soroban"; import { swapIdentityKey } from "popup/helpers/swapQuote"; @@ -58,6 +59,7 @@ export const SwapAsset = ({ }: SwapAssetProps) => { const { t } = useTranslation(); const networkDetails = useSelector(settingsNetworkDetailsSelector); + const publicKey = useSelector(publicKeySelector); // Token discovery (Popular + search) only exists on Mainnet/Testnet. On // custom/Futurenet networks the picker degrades to held-only and the Popular // section is simply hidden — no "discovery unavailable" notice. @@ -322,7 +324,7 @@ export const SwapAsset = ({ }; getDecimalsForAsset({ assetIssuer: splitCanonical(canonical).issuer, - publicKey: resolvedFromData?.publicKey ?? "", + publicKey, networkDetails, }) .then((decimals) => {