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/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/@shared/api/internal.ts b/@shared/api/internal.ts index 9ac546bf10..bbaf697b7a 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; @@ -1537,6 +1540,11 @@ export const getTokenDetails = async ({ }${shouldFetchBalance ? "&should_fetch_balance=true" : ""}`, { signal }, ); + // 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) { + return null; + } const data = await response.json(); if (!response.ok) { throw new Error(data); @@ -1553,6 +1561,9 @@ export const getTokenDetails = async ({ error, )} - ${contractId} - ${networkDetails.network}`, ); + if (throwOnFailure) { + throw error; + } return null; } }; 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 0d31509809..2525394c8c 100644 --- a/extension/e2e-tests/swap.test.ts +++ b/extension/e2e-tests/swap.test.ts @@ -29,7 +29,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): @@ -55,7 +54,7 @@ import { TESTNET_TRENDING_ASSETS_ROUTE, TRENDING_ASSETS_ROUTE, } 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"; @@ -435,11 +434,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, @@ -447,12 +444,13 @@ 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 context.unroute(ASSET_SEARCH_ROUTE); await context.route(ASSET_SEARCH_ROUTE, (route) => - route.fulfill(assetSearchResponse([])), + route.fulfill( + assetSearchResponse([ + { asset: SOROBAN_CONTRACT_ADDRESS, code: "TKN", decimals: 18 }, + ]), + ), ); await context.route(TRENDING_ASSETS_ROUTE, (route) => route.fulfill(assetSearchResponse([])), @@ -466,18 +464,12 @@ 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(); + await expect(page.getByTestId("SwapTokenRow-TKN")).toContainText("CAZX…XSND"); }); // --------------------------------------------------------------------------- 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/__tests__/AssetDetail.test.tsx b/extension/src/popup/components/__tests__/AssetDetail.test.tsx index 87d6c800a8..fb8203f36d 100644 --- a/extension/src/popup/components/__tests__/AssetDetail.test.tsx +++ b/extension/src/popup/components/__tests__/AssetDetail.test.tsx @@ -1062,7 +1062,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 = { @@ -1104,7 +1104,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 72236a685a..0cceb42c59 100644 --- a/extension/src/popup/components/account/AssetDetail/index.tsx +++ b/extension/src/popup/components/account/AssetDetail/index.tsx @@ -415,7 +415,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. diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index dd81cffefb..affb8b9e4b 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -9,6 +9,7 @@ import { isNativeAssetPair } from "@shared/helpers/assetIdentity"; import { SecurityLevel } from "popup/constants/blockaid"; import { InputType } from "helpers/transaction"; import { formatAmountPreserveCursor } from "popup/helpers/formatters"; +import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; import { useRunAfterUpdate } from "popup/helpers/useRunAfterUpdate"; import "./styles.scss"; @@ -49,7 +50,8 @@ export interface AmountCardProps { * (e.g. the swap CTA disables itself while the sell input is focused). */ onInputFocus?: () => void; onInputBlur?: () => void; - cryptoDecimals: number; + /** Input precision; a read-only card never uses it. */ + cryptoDecimals?: number; onAmountChange: (next: { amount: string; newCursor: number }) => void; onAmountUsdChange: (next: { amount: string; newCursor: number }) => void; onToggleInputType: () => void; @@ -80,7 +82,7 @@ export const AmountCard = ({ amountInputRef, onInputFocus, onInputBlur, - cryptoDecimals, + cryptoDecimals = CLASSIC_ASSET_DECIMALS, onAmountChange, onAmountUsdChange, onToggleInputType, 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/__tests__/SwapAmount.liveQuote.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.liveQuote.test.tsx index c33f64be6a..dcff7669f7 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 @@ -235,6 +235,30 @@ describe("SwapAmount live receive-amount quote", () => { expectQuoteUnavailable(); }); + 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, + ); + expectQuoteUnavailable(); + }); + it("disables Review swap on a 400 without reporting it", async () => { fetchBackendV2.mockResolvedValue({ status: 400, 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 4cf255dfea..30101a681c 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 @@ -114,7 +114,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 @@ -186,6 +189,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 ({ body }) => JSON.parse(body!).mode === "send" 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..ea5802dce2 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.sorobanTokens.test.tsx @@ -0,0 +1,281 @@ +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"; +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"; +import { getSwapDerivedData } from "popup/components/swap/SwapAmount/helpers/getSwapDerivedData"; +import { getSwapQuoteKey } from "popup/helpers/swapQuote"; + +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 }); + expect(lastSimParams().destDecimals).toBeUndefined(); + }); + + it("builds an unheld contract token with the decimals read at pick", () => { + renderSwapAmount({ + destinationAsset: TOKEN, + destinationTokenDetails: { + tokenCode: "TKN", + issuer: TOKEN_CONTRACT, + requiresTrustline: false, + decimals: 6, + }, + }); + + expect(lastSimParams().destDecimals).toBe(6); + }); + + it("leaves a destination that never went through the picker to be read at build", () => { + renderSwapAmount({ + destinationAsset: TOKEN, + destinationTokenDetails: null, + }); + + expect(lastSimParams().destDecimals).toBeUndefined(); + }); + + it("adds no trustline into a contract token the account doesn't hold", () => { + renderSwapAmount({ destinationAsset: TOKEN }); + + 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 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({ + 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", + publicKey: "G123", + swapQuoteKey: getSwapQuoteKey({ + mode: "send", + amount: "1", + sourceAsset: TOKEN, + destAsset: USDC, + networkDetails: MAINNET_NETWORK_DETAILS, + publicKey: "G123", + }), + 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/__tests__/swapAmountHelpers.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts index 0387ff17ca..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,17 +7,32 @@ 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, true)).toBeNull(); + expect(validateSwapAmount("0.1234567", 7, true)).toBeNull(); + expect(validateSwapAmount("", 7, true)).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, 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, 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")).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/getSwapDerivedData.ts b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts index cadeef2c38..d0a145ac4e 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/getSwapDerivedData.ts @@ -9,7 +9,8 @@ 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, isContractId } from "popup/helpers/soroban"; +import { swapIdentityKey } from "popup/helpers/swapQuote"; import { findAssetBalance, getBalanceCanonicalKey, @@ -46,7 +47,6 @@ interface GetSwapDerivedDataParams { data: ResolvedSwapAmountData; asset: string; destinationAsset: string; - isToken: boolean; destinationAmount: string; destinationTokenDetails: DestinationTokenDetails | null; /** formik.values.amount */ @@ -77,7 +77,6 @@ export const getSwapDerivedData = ({ data, asset, destinationAsset, - isToken, destinationAmount, destinationTokenDetails, amount, @@ -102,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 @@ -120,7 +122,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 @@ -148,11 +149,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), @@ -223,8 +226,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, @@ -248,7 +250,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..1c4dfc6c0a 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountValidation.ts @@ -8,12 +8,17 @@ 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, + // 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 > 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))) { + 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/hooks/__tests__/useSimulateSwapData.i18n.test.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.i18n.test.tsx index c879b9d6e8..55f99fb570 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 = ( recommendedFee: "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 e8aad366f4..cf938f973a 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"; @@ -19,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"; @@ -156,6 +161,69 @@ 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. @@ -178,6 +246,8 @@ const renderSimulate = ( recommendedFee: "0.00001", transactionTimeout: 180, destRequiresTrustline: false, + sourceDecimals: 7, + destDecimals: 7, ...simParams, }, }), @@ -679,6 +749,140 @@ describe("useSimulateTxData quote when no live quote exists", () => { expect(result.current.state.error).toBe("No quote available"); }); + 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("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, + build: sorobanBuild("10000000"), + }), + ); + const { result } = renderSimulate(undefined, { + destAsset: tokenAsset, + destDecimals: undefined, + }); + + await act(async () => { + await result.current.fetchData({ amount: "1" }); + }); + + 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") }, + ), + ); + 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(); + }); + }); + 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 43dab69c27..7a590046d0 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_QUOTE), - contractAsset, - classicAsset, - ); - expect(result).toBe(ERROR_TO_DISPLAY.NO_QUOTE); - }); - - it("returns known error message for classic assets", () => { - const result = getSwapErrorMessage( - new Error(ERROR_TO_DISPLAY.NO_QUOTE), - classicAsset, - classicAsset, + it("returns a known error message", () => { + expect(getSwapErrorMessage(new Error(ERROR_TO_DISPLAY.NO_QUOTE))).toBe( + ERROR_TO_DISPLAY.NO_QUOTE, ); - expect(result).toBe(ERROR_TO_DISPLAY.NO_QUOTE); }); - 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_QUOTE, - classicAsset, - classicAsset, - ); + it("returns known error message for string errors", () => { + const result = getSwapErrorMessage(ERROR_TO_DISPLAY.NO_QUOTE); expect(result).toBe(ERROR_TO_DISPLAY.NO_QUOTE); }); - 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 4f4f5530f4..6590e02c8d 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -45,28 +45,27 @@ 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, + getDecimalsForAsset, +} from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { AppDispatch } from "popup/App"; const scanUrlstub = "internal"; export const ERROR_TO_DISPLAY = { - CUSTOM_TOKEN_NOT_SUPPORTED: "Swapping custom tokens is not supported yet.", 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", }; 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 @@ -76,10 +75,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; }; @@ -100,6 +95,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; + /** From the held balance; otherwise read on chain at build time. */ + sourceDecimals?: number; + destDecimals?: number; } export interface SimulateTxData { @@ -245,17 +243,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, recommendedFee, customFee, destRequiresTrustline, @@ -265,6 +269,7 @@ const buildFromBackend = async ({ quotes: SwapQuotesResponse | null; key: SwapQuoteKey; allowedSlippage: string; + destDecimals: number; recommendedFee: string; customFee?: string; destRequiresTrustline: boolean; @@ -275,7 +280,7 @@ const buildFromBackend = async ({ if (!route) { throw new Error(ERROR_TO_DISPLAY.NO_QUOTE); } - const minOut = getMinOut(allowedSlippage, route.toAmount); + const minOut = getMinOut(allowedSlippage, route.toAmount, destDecimals); // Only a classic build splits the fee across ops (changeTrust + path // payment); a LI.FI build is one op that takes the whole fee. const fee = @@ -384,6 +389,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 { recommendedFee } = simParams; // The local build is always classic. const baseFee = new BigNumber( @@ -479,12 +508,17 @@ function useSimulateTxData({ ) .build() .toXdr(), - minOut: getMinOut(allowedSlippage, destinationAmount), + minOut: getMinOut( + allowedSlippage, + destinationAmount, + CLASSIC_ASSET_DECIMALS, + ), } : await buildFromBackend({ quotes, key: getSwapQuoteKey(quoteParams), allowedSlippage, + destDecimals, recommendedFee, customFee, destRequiresTrustline: simParams.destRequiresTrustline, @@ -504,6 +538,7 @@ function useSimulateTxData({ destAsset: quoteParams.destAsset, amount, minOut, + sourceDecimals, }); } catch (e) { if (!isStale()) { @@ -542,9 +577,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 4398ebb6c2..d950b5af64 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"; @@ -38,8 +39,16 @@ 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 { getBalanceCanonicalKey } from "popup/helpers/balance"; +import { + CLASSIC_ASSET_DECIMALS, + getAvailableBalance, + isContractId, + isSacContract, +} from "popup/helpers/soroban"; +import { + findAssetBalance, + getBalanceCanonicalKey, +} from "popup/helpers/balance"; import { useBlockaidOverrideState } from "popup/helpers/blockaid"; import { AppDispatch } from "popup/App"; import { emitMetric, emitScreenViewed } from "helpers/metrics"; @@ -73,7 +82,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 { getHeldTokenDecimals, getSwapQuote } from "popup/helpers/swapQuote"; import { SheetHeader } from "popup/components/SheetHeader"; import { SheetLayout } from "popup/components/SheetLayout"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; @@ -120,7 +129,6 @@ export const SwapAmount = ({ destination, destinationAmount, destinationAsset, - isToken, memo, path, transactionFee, @@ -157,10 +165,34 @@ export const SwapAmount = ({ ? (swapAmountData.data.userBalances.unfilteredBalances ?? swapAmountData.data.userBalances.balances) : []; + 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, 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(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 @@ -189,6 +221,11 @@ export const SwapAmount = ({ transactionTimeout, memo, destRequiresTrustline, + sourceDecimals: heldSourceDecimals, + // Read at pick for an unheld contract token; a destination that never + // went through the picker is read at build. + destDecimals: + heldDestDecimals ?? transactionData.destinationTokenDetails?.decimals, }, }); @@ -290,7 +327,11 @@ export const SwapAmount = ({ }; const validate = (values: { amount: string }) => { - const error = validateSwapAmount(values.amount); + const error = validateSwapAmount( + values.amount, + sourceDecimals, + !isContractId(srcAsset?.issuer), + ); return error ? { amount: error } : {}; }; @@ -436,7 +477,6 @@ export const SwapAmount = ({ destinationIsNonHeld, assetPrice, dstAssetPrice, - assetDecimals, priceValueUsd, supportsUsd, dstPriceValueUsd, @@ -454,7 +494,6 @@ export const SwapAmount = ({ data, asset, destinationAsset, - isToken, destinationAmount, destinationTokenDetails: transactionData.destinationTokenDetails, amount: formik.values.amount, @@ -485,6 +524,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 @@ -723,7 +764,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; @@ -740,7 +781,7 @@ export const SwapAmount = ({ const crypto = assetPrice ? new BigNumber(cleanAmount(usd)) .dividedBy(assetPrice) - .decimalPlaces(assetDecimals) + .decimalPlaces(sourceDecimals) .toFixed() : DEFAULT_AMOUNT; formik.setFieldValue("amount", crypto); @@ -783,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)); @@ -845,7 +892,6 @@ export const SwapAmount = ({ isAmountLoading={isQuotePending} isReadOnly autoFocus={false} - cryptoDecimals={7} onAmountChange={() => {}} onAmountUsdChange={() => {}} onToggleInputType={() => {}} @@ -892,7 +938,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/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx b/extension/src/popup/components/swap/SwapAsset/SwapPickerSections/__tests__/SwapPickerSections.test.tsx index 8f1e155e21..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 @@ -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) => ({ @@ -44,7 +48,6 @@ const emptyResult = { popular: [], verified: [], unverified: [], - hadSorobanMatches: false, isFallback: false, isNewAccount: false, }; @@ -52,11 +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. - isDestination: true, }; 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( { ); }); - it("Soroban empty state shown when hadSorobanMatches", () => { - 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", () => { + it("shows the generic empty state for a pasted contract id with no matches", () => { 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(); }); @@ -230,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( ({ tokenCode: r.code ?? "", requiresTrustline: r.requiresTrustline, - decimals: 7, + // An unheld contract token's decimals are read when it is picked, not + // taken from the search record. + decimals: r.isContract + ? r.isHeld + ? r.decimals + : undefined + : CLASSIC_ASSET_DECIMALS, issuer: r.issuer || undefined, securityLevel: r.securityLevel, securityWarnings: r.securityWarnings, @@ -48,7 +53,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 +70,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 +83,6 @@ export const SwapPickerSections = ({ hiddenAssets = [], onClickAsset, stellarExpertUrl, - isDestination, isDiscoverySupported = false, }: SwapPickerSectionsProps) => { const { t } = useTranslation(); @@ -96,11 +94,13 @@ export const SwapPickerSections = ({ // Exclude hidden canonicals (the other side's asset) from the discover // sections. "Your tokens" is intentionally NOT filtered — every held token // should stay visible even when it is already selected on the other side. - const hidden = new Set(hiddenAssets); + const hidden = new Set(hiddenAssets.map(swapIdentityKey)); + const isShown = (r: SwapTokenRecord) => + !hidden.has(swapIdentityKey(r.canonical)); const yourTokens = result.yourTokens; - const popular = result.popular.filter((r) => !hidden.has(r.canonical)); - const verified = result.verified.filter((r) => !hidden.has(r.canonical)); - const unverified = result.unverified.filter((r) => !hidden.has(r.canonical)); + const popular = result.popular.filter(isShown); + const verified = result.verified.filter(isShown); + const unverified = result.unverified.filter(isShown); // Held "Your tokens" rows use BalanceRow, matching the account-home balances // list, divided like it but with no line after the last row, where the next @@ -148,7 +148,9 @@ export const SwapPickerSections = ({ bodyTestId={`SwapTokenRow-${code}-body`} code={code} issuer={r.issuer} - domain={r.domain} + // A contract token shows its contract ID, so a lookalike of a + // known token still looks different. + domain={r.isContract ? truncateString(r.issuer ?? "") : r.domain} iconUrl={r.image ?? r.icon ?? undefined} isSuspicious={isSuspicious} isMalicious={isMalicious} @@ -187,17 +189,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 ? (
({ 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 ( 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 52c99fa647..15bab68c2a 100644 --- a/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx +++ b/extension/src/popup/components/swap/SwapAsset/__tests__/SwapAsset.test.tsx @@ -1,13 +1,17 @@ import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { captureException } from "@sentry/browser"; import { Wrapper } from "popup/__testHelpers__"; import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; import * as UseSwapFromData from "popup/components/swap/SwapAsset/hooks/useSwapFromData"; import * as UseSwapTokenLookup from "popup/components/swap/SwapAsset/hooks/useSwapTokenLookup"; +import * as Soroban from "popup/helpers/soroban"; import { SwapAsset } from "popup/components/swap/SwapAsset"; +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); + const resolvedFromState = { state: RequestState.SUCCESS, data: { @@ -30,7 +34,6 @@ const emptyLookupResult = { unverified: [], }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }; @@ -112,7 +115,6 @@ describe("SwapAsset selectionType", () => { unverified: [], }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }, error: null, @@ -242,3 +244,288 @@ describe("SwapAsset selectionType", () => { ); }); }); + +describe("SwapAsset destination: a contract token the account doesn't hold", () => { + const CONTRACT = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + const tokenRow = { + canonical: `TKN:${CONTRACT}`, + code: "TKN", + issuer: CONTRACT, + contract: CONTRACT, + domain: null, + isHeld: false, + isContract: true, + requiresTrustline: false, + // stellar.expert's (toml-sourced) value: never what the pick stores. + decimals: 6, + }; + const aquaRow = { + canonical: "AQUA:G456", + code: "AQUA", + issuer: "G456", + domain: "aqua.network", + isHeld: false, + isContract: false, + requiresTrustline: true, + }; + + const renderPicker = (onClickAsset = jest.fn()) => { + jest.spyOn(UseSwapFromData, "useGetSwapFromData").mockReturnValue({ + state: resolvedFromState, + 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: [aquaRow, tokenRow], + verified: [], + unverified: [], + }, + isSearch: false, + isFallback: false, + }, + error: null, + }, + } as any); + render( + + + , + ); + return onClickAsset; + }; + + afterEach(() => jest.restoreAllMocks()); + + it("reads its decimals from the backend before the pick applies", async () => { + const getDecimals = jest + .spyOn(Soroban, "getDecimalsForAsset") + .mockResolvedValue(18); + const onClickAsset = renderPicker(); + + fireEvent.click(screen.getByTestId("SwapTokenRow-TKN-body")); + + await waitFor(() => expect(onClickAsset).toHaveBeenCalledTimes(1)); + expect(getDecimals).toHaveBeenCalledWith( + expect.objectContaining({ assetIssuer: CONTRACT, publicKey: "G123" }), + ); + expect(onClickAsset.mock.calls[0][2]).toMatchObject({ + tokenCode: "TKN", + decimals: 18, + }); + }); + + it("refuses the pick when the decimals can't be read, and says so", async () => { + jest + .spyOn(Soroban, "getDecimalsForAsset") + .mockRejectedValue(new Error("rpc down")); + const onClickAsset = renderPicker(); + + fireEvent.click(screen.getByTestId("SwapTokenRow-TKN-body")); + + expect( + await screen.findByText( + "Couldn't read this token's details. Please try again.", + ), + ).toBeInTheDocument(); + expect(onClickAsset).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalled(); + }); + + it("ignores a repeat tap while the read is pending", async () => { + let resolve: (n: number) => void = () => {}; + const getDecimals = jest + .spyOn(Soroban, "getDecimalsForAsset") + .mockReturnValue(new Promise((r) => (resolve = r))); + const onClickAsset = renderPicker(); + + fireEvent.click(screen.getByTestId("SwapTokenRow-TKN-body")); + fireEvent.click(screen.getByTestId("SwapTokenRow-TKN-body")); + expect(getDecimals).toHaveBeenCalledTimes(1); + + resolve(18); + await waitFor(() => expect(onClickAsset).toHaveBeenCalledTimes(1)); + }); + + it("drops a late read when a newer pick was made", async () => { + let resolve: (n: number) => void = () => {}; + jest + .spyOn(Soroban, "getDecimalsForAsset") + .mockReturnValue(new Promise((r) => (resolve = r))); + const onClickAsset = renderPicker(); + + fireEvent.click(screen.getByTestId("SwapTokenRow-TKN-body")); + fireEvent.click(screen.getByTestId("SwapTokenRow-AQUA-body")); + expect(onClickAsset).toHaveBeenCalledTimes(1); + expect(onClickAsset.mock.calls[0][0]).toBe("AQUA:G456"); + + resolve(18); + await new Promise((r) => setTimeout(r, 0)); + 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(); + }); +}); + +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/hooks/__tests__/useSwapTokenLookup.abort.test.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.abort.test.tsx index f27d419ea6..933ba16ef6 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 new file mode 100644 index 0000000000..64cac4177d --- /dev/null +++ b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.soroban.test.tsx @@ -0,0 +1,256 @@ +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: {} as Record, + }, +}; +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(), +})); +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"; +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, + }), + ]); + }); + + 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", + 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"); + }); + }); +}); + +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/__tests__/useSwapTokenLookup.test.ts b/extension/src/popup/components/swap/SwapAsset/hooks/__tests__/useSwapTokenLookup.test.ts index ffdd83ff7e..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 @@ -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,140 @@ 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: [wrap], + }); + + expect(result.sections.unverified).toEqual([ + expect.objectContaining({ + code: "WRAP", + canonical: `WRAP:${CONTRACT}`, + isContract: true, + requiresTrustline: false, + decimals: 18, + }), + ]); + }); +}); + +describe("buildSwapSections — a contract token is one identity", () => { + // Held: the on-chain symbol; search: stellar.expert's code. Same contract. + const heldSoroban = { + token: { code: "SYM", issuer: { key: CONTRACT } }, + contractId: CONTRACT, + total: "1000000", + decimals: 6, + } as any; + const searchRow = { + code: "ABC", + issuer: CONTRACT, + contract: CONTRACT, + domain: null, + }; + + 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], + networkDetails: MAINNET, + verifiedAssets: [searchRow], 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([]); - expect(result.sections.unverified).toEqual([]); - expect(result.hadSorobanMatches).toBe(true); + }); + + it("keeps a Popular row for a held contract out, whatever code it carries", () => { + const result = buildSwapSections({ + searchTerm: "", + balances: [heldSoroban], + networkDetails: MAINNET, + popular: [{ ...searchRow, volume7d: 9 }], + verifiedAssets: [searchRow], + }); + + expect(result.sections.popular).toEqual([]); + }); + + it("hides a contract token by its contract, whatever code the row carries", () => { + const result = buildSwapSections({ + searchTerm: "abc", + balances: [], + networkDetails: MAINNET, + verifiedAssets: [searchRow], + unverifiedAssets: [], + }); + + expect( + omitHiddenFromSections(result, { [`SYM:${CONTRACT}`]: "hidden" }).sections + .verified, + ).toEqual([]); + }); +}); + +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([]); }); }); @@ -177,7 +323,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 +343,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 feb63c26df..7dc20ecf6a 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapTokenLookup.ts @@ -11,18 +11,27 @@ 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"; import { RequestState } from "constants/request"; -import { isMainnet, isTestnet, getCanonicalFromAsset } from "helpers/stellar"; +import { + isMainnet, + isTestnet, + getCanonicalFromAsset, + truncateString, +} from "helpers/stellar"; import { isNativeAssetPair, isNativeBalance, } 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, @@ -45,6 +54,7 @@ import { import { settingsSelector } from "popup/ducks/settings"; import { tokensListsSelector, + iconsSelector, saveTokenLists, popularTokensSelector, savePopularTokens, @@ -55,6 +65,7 @@ import { fetchTrendingAssets, TrendingAsset, } from "popup/helpers/trendingAssets"; +import { swapIdentityKey } from "popup/helpers/swapQuote"; // Re-export RequestState for consumers export { RequestState }; @@ -85,14 +96,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 +119,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 +138,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 +162,9 @@ const heldToRecord = ( fiatValue, percentChange24h, isHeld: true, - isContract: false, + isContract: sorobanDecimals !== undefined, requiresTrustline: false, + ...(sorobanDecimals !== undefined ? { decimals: sorobanDecimals } : {}), }; }; @@ -188,13 +198,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,30 +217,24 @@ 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, balances, + networkDetails, popular = [], verifiedAssets = [], unverifiedAssets = [], - searchResults = [], isFallback = false, icons = {}, tokenPrices, }: { 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[]; unverifiedAssets?: ManageAssetCurrency[]; - searchResults?: ManageAssetCurrency[]; isFallback?: boolean; icons?: Record; tokenPrices?: ApiTokenPrices | null; @@ -237,31 +243,18 @@ export const buildSwapSections = ({ const isSearch = term.length > 0; 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; - }; + const heldKeys = new Set( + heldRecords.map((r) => swapIdentityKey(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 const verifiedKeys = new Set( verifiedAssets.map((a) => - getCanonicalFromAsset(a.code || "", a.issuer || ""), + swapIdentityKey(getCanonicalFromAsset(a.code || "", a.issuer || "")), ), ); const popularRecords = popular @@ -272,14 +265,15 @@ export const buildSwapSections = ({ contract: p.contract, domain: p.domain, image: p.icon, + decimals: p.decimals, }), ) - .filter(isClassic) .map((a) => currencyToRecord(a, false)) - .filter( - (r) => - !heldCanonicals.has(r.canonical) && verifiedKeys.has(r.canonical), - ); + .filter(isDiscoverable) + .filter((r) => { + const key = swapIdentityKey(r.canonical); + return !heldKeys.has(key) && verifiedKeys.has(key); + }); return { sections: { @@ -289,7 +283,6 @@ export const buildSwapSections = ({ unverified: [], }, isSearch: false, - hadSorobanMatches, isFallback, }; } @@ -306,32 +299,35 @@ export const buildSwapSections = ({ return { sections: { yourTokens, popular: [], verified: [], unverified: [] }, isSearch: true, - hadSorobanMatches: false, isFallback: true, }; } - const heldSearchKeys = new Set(yourTokens.map((r) => r.canonical)); + const heldSearchKeys = new Set( + yourTokens.map((r) => swapIdentityKey(r.canonical)), + ); - // Dedupe by canonical, exclude held (already in yourTokens) + // 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 - .filter(isClassic) - .map((a) => currencyToRecord(a, false)) + .map((a) => { + const { canonical } = currencyToRecord(a, false); + return currencyToRecord(a, heldKeys.has(swapIdentityKey(canonical))); + }) + .filter(isDiscoverable) .filter((r) => { - if (heldSearchKeys.has(r.canonical) || seen.has(r.canonical)) { + const key = swapIdentityKey(r.canonical); + if (heldSearchKeys.has(key) || seen.has(key)) { return false; } - seen.add(r.canonical); + seen.add(key); return true; }); }; - // 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 +336,6 @@ export const buildSwapSections = ({ unverified: dedupe(unverifiedAssets), }, isSearch: true, - hadSorobanMatches, isFallback: false, }; }; @@ -365,8 +360,16 @@ export const omitHiddenFromSections = ( if (!Object.keys(hiddenAssets).length) { return result; } + const hiddenByKey = Object.fromEntries( + Object.entries(hiddenAssets).map(([canonical, visibility]) => [ + swapIdentityKey(canonical), + visibility, + ]), + ); const visible = (rows: SwapTokenRecord[]) => - rows.filter((r) => isAssetVisible(hiddenAssets, r.canonical)); + rows.filter((r) => + isAssetVisible(hiddenByKey, swapIdentityKey(r.canonical)), + ); return { ...result, sections: { @@ -378,6 +381,49 @@ 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 cachedByKey = Object.fromEntries( + Object.entries(cachedIcons).map(([canonical, icon]) => [ + swapIdentityKey(canonical), + icon, + ]), + ); + const withIcon = (r: SwapTokenRecord): SwapTokenRecord => { + if (!r.issuer) { + return r; + } + const image = + cachedByKey[swapIdentityKey(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, @@ -440,17 +486,61 @@ 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 => { 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, image: record.tomlInfo?.image, price: record.price, + decimals: record.decimals, }; } return { @@ -643,18 +733,24 @@ 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 - const resJson = await searchAsset({ - asset: searchTerm.trim(), - networkDetails, - signal, - }); - 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, @@ -701,6 +797,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({ @@ -712,31 +809,34 @@ export const useSwapTokenLookup = () => { verifiedAssets = split.verifiedAssets; } - let payload = buildSwapSections({ - searchTerm, - balances, - networkDetails, - icons, - tokenPrices, - popular, - verifiedAssets, - unverifiedAssets, - searchResults, - }); + 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 // (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..7d3b4c1272 100644 --- a/extension/src/popup/components/swap/SwapAsset/index.tsx +++ b/extension/src/popup/components/swap/SwapAsset/index.tsx @@ -1,6 +1,7 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useRef } from "react"; import { Navigate } from "react-router-dom"; -import { Icon, Input, Loader } from "@stellar/design-system"; +import { Icon, Input, Loader, Notification } from "@stellar/design-system"; +import { captureException } from "@sentry/browser"; import { useFormik } from "formik"; import { debounce } from "lodash"; import { useTranslation } from "react-i18next"; @@ -17,7 +18,11 @@ 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"; +import { splitCanonical } from "@shared/helpers/stellar"; import { useGetSwapFromData } from "./hooks/useSwapFromData"; import { useSwapTokenLookup, @@ -54,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. @@ -80,6 +86,17 @@ export const SwapAsset = ({ // for the whole fetch promise — that kept the spinner up through the silent // Blockaid revalidation / cached-result repaint. const [isSearchPending, setIsSearchPending] = React.useState(false); + const [pickError, setPickError] = React.useState(null); + // A contract-token pick waits on its decimals; a newer pick or the picker + // closing wins over a late result. + const pickIdRef = useRef(0); + const pendingPickRef = useRef(null); + useEffect( + () => () => { + pickIdRef.current += 1; + }, + [], + ); const isLoading = isDestination ? lookupState.state === RequestState.IDLE || @@ -240,7 +257,6 @@ export const SwapAsset = ({ const pickerResult: SwapPickerSectionsResult = lookupData ? { ...lookupData.sections, - hadSorobanMatches: lookupData.hadSorobanMatches, isFallback: lookupData.isFallback, isNewAccount: heldBalancesForNewAccount.length === 0, } @@ -249,11 +265,88 @@ export const SwapAsset = ({ popular: [], verified: [], unverified: [], - hadSorobanMatches: false, isFallback: false, isNewAccount: true, }; + // An unheld contract token's decimals scale every amount sent for it, so + // 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. + // 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, + isContract: boolean, + details?: SwapPickerSelection, + ) => { + if (pendingPickRef.current === canonical) { + return; + } + // Every pick supersedes a pending read, whether or not it needs one. + pickIdRef.current += 1; + const held = isContract + ? heldByKey.get(swapIdentityKey(canonical)) + : undefined; + if (!isContract || held) { + pendingPickRef.current = null; + 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; + pendingPickRef.current = canonical; + setPickError(null); + const isLatest = () => { + if (pickId !== pickIdRef.current) { + return false; + } + pendingPickRef.current = null; + return true; + }; + getDecimalsForAsset({ + assetIssuer: splitCanonical(canonical).issuer, + publicKey, + networkDetails, + }) + .then((decimals) => { + if (isLatest()) { + onClickAsset( + canonical, + isContract, + details && { ...details, decimals }, + ); + } + }) + .catch((e) => { + if (!isLatest()) { + return; + } + captureException(e); + setPickError( + t("Couldn't read this token's details. Please try again."), + ); + }); + }; + // The source ("Swap from") picker shows the same held "Your tokens" list as // the destination — held tokens only, filtered locally by the search term. const sourceResult: SwapPickerSectionsResult = { @@ -261,7 +354,6 @@ export const SwapAsset = ({ popular: [], verified: [], unverified: [], - hadSorobanMatches: false, isFallback: false, isNewAccount: heldBalancesForNewAccount.length === 0, }; @@ -294,15 +386,24 @@ export const SwapAsset = ({
) : isDestination ? ( - + <> + {pickError && ( +
+ +
+ )} + + ) : ( )}
diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 5f12ec4269..9770cb79f5 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -457,8 +457,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 6396a02db8..6ab8268813 100644 --- a/extension/src/popup/helpers/__tests__/decodeSwapTx.test.ts +++ b/extension/src/popup/helpers/__tests__/decodeSwapTx.test.ts @@ -68,8 +68,47 @@ const decode = ( destAsset: USDC, amount, 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"); @@ -245,6 +284,73 @@ describe("decodeSwapTx", () => { ).toThrow(/trustline/); }); + describe("Soroban contract tokens", () => { + const decodeToken = ( + transactionXdr: string, + sourceAsset: string, + destAsset: string, + sourceDecimals: number, + amount: string, + ) => + decodeSwapTx({ + transactionXdr, + networkPassphrase: Networks.PUBLIC, + account: ACCOUNT, + sourceAsset, + destAsset, + amount, + 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, + "1.5", + ); + + 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, + "2.5", + ).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, + "1", + ), + ).toThrow(/transfer/); + }); + + it("refuses a classic path payment for a contract token", () => { + expect(() => + decodeToken(classicTx(), "native", TOKEN, 7, "10"), + ).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 e215c64299..877a2cb8f7 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 { getHeldTokenDecimals, getSwapQuote } from "../swapQuote"; jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); @@ -318,6 +318,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", @@ -408,3 +430,28 @@ describe("getSwapQuote", () => { }); }); }); + +describe("getHeldTokenDecimals", () => { + const TOKEN = `TKN:${TOKEN_CONTRACT}`; + const heldToken = { + token: { code: "TKN", issuer: { key: TOKEN_CONTRACT } }, + contractId: TOKEN_CONTRACT, + decimals: 18, + } as any; + + it("reads a held contract token's decimals from its balance", () => { + expect(getHeldTokenDecimals(TOKEN, [heldToken])).toBe(18); + }); + + it("is undefined for a contract token not held", () => { + expect(getHeldTokenDecimals(TOKEN, [])).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/__tests__/trendingAssets.test.ts b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts index aeebb1cf4e..3b349d848c 100644 --- a/extension/src/popup/helpers/__tests__/trendingAssets.test.ts +++ b/extension/src/popup/helpers/__tests__/trendingAssets.test.ts @@ -27,7 +27,13 @@ const TESTNET: NetworkDetails = { } as NetworkDetails; const respondWithRecords = ( - records: { asset: string; volume7d: number; domain?: string }[], + records: { + asset: string; + volume7d: number; + domain?: string; + code?: string; + decimals?: number; + }[], ) => mockedSend.mockResolvedValue({ status: 200, @@ -86,6 +92,40 @@ describe("fetchTrendingAssets", () => { expect(result[0].issuer).toBe("GBIG"); }); + it("keeps Soroban contract tokens, by contract ID and with their decimals", async () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + respondWithRecords([ + { + asset: contract, + code: "TKN", + decimals: 6, + volume7d: MIN_TRENDING_VOLUME7D + 5, + }, + ]); + + const result = await fetchTrendingAssets({ networkDetails: MAINNET }); + + expect(result).toEqual([ + expect.objectContaining({ + code: "TKN", + issuer: contract, + contract, + decimals: 6, + }), + ]); + }); + + it("names a contract token with no code by its shortened contract ID", async () => { + const contract = "CBI7UCH5KGSVQRO5H4SUCZUTZABCITZLRHQQZTWL2TK4RZ72TAR6IHRV"; + respondWithRecords([ + { asset: contract, decimals: 7, volume7d: MIN_TRENDING_VOLUME7D + 5 }, + ]); + + 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 () => { respondWithRecords([ { asset: "A-GA", volume7d: 0 }, diff --git a/extension/src/popup/helpers/decodeSwapTx.ts b/extension/src/popup/helpers/decodeSwapTx.ts index 282af2e5e5..33cd5f1b0c 100644 --- a/extension/src/popup/helpers/decodeSwapTx.ts +++ b/extension/src/popup/helpers/decodeSwapTx.ts @@ -10,6 +10,7 @@ import { 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. */ @@ -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 (!isContractId(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,11 +69,14 @@ const atLeastMinOut = (amount: BigNumber, minOut: string) => { const decodeClassic = ( tx: Transaction, account: string, - from: Asset, - to: Asset, + { classic: from }: SwapToken, + { classic: to }: SwapToken, amount: string, 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; @@ -128,17 +146,15 @@ const contractCalls = ( const decodeSoroban = ( tx: Transaction, account: string, - from: Asset, - to: Asset, + { contract: fromContract }: SwapToken, + { contract: toContract }: SwapToken, amount: string, - networkPassphrase: string, + 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; @@ -170,7 +186,7 @@ const decodeSoroban = ( throw mismatch(name); } }); - if (!sent.gt(0) || !sent.shiftedBy(-CLASSIC_DECIMALS).eq(amount)) { + if (!sent.gt(0) || !sent.shiftedBy(-sourceDecimals).eq(amount)) { throw mismatch("amount"); } @@ -179,7 +195,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()) } @@ -202,6 +218,7 @@ export const decodeSwapTx = ({ destAsset, amount, minOut, + sourceDecimals, }: { transactionXdr: string; networkPassphrase: string; @@ -212,6 +229,8 @@ export const decodeSwapTx = ({ amount: 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) { @@ -224,13 +243,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, amount, networkPassphrase); + return decodeSoroban(tx, account, from, to, amount, sourceDecimals); } return decodeClassic(tx, account, from, to, amount, minOut); }; diff --git a/extension/src/popup/helpers/swapQuote.ts b/extension/src/popup/helpers/swapQuote.ts index 78b5372649..778ed217f6 100644 --- a/extension/src/popup/helpers/swapQuote.ts +++ b/extension/src/popup/helpers/swapQuote.ts @@ -1,5 +1,5 @@ import BigNumber from "bignumber.js"; -import { Horizon, StrKey } from "stellar-sdk"; +import { Horizon } from "stellar-sdk"; import { getSwapQuotes, @@ -10,6 +10,7 @@ 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 { @@ -17,7 +18,9 @@ import { isCustomNetwork, splitCanonical, } from "@shared/helpers/stellar"; +import { findAssetBalance } from "popup/helpers/balance"; import { cleanAmount } from "popup/helpers/formatters"; +import { isContractId } from "popup/helpers/soroban"; import { horizonGetBestPath, horizonGetBestReceivePath, @@ -98,12 +101,36 @@ export const pickSwapRoute = ( quotes: SwapQuotesResponse, ): SwapRoute | undefined => quotes.routes[0]; +const isContractToken = (canonical: string) => + isContractId(splitCanonical(canonical).issuer); + +/** + * What makes two swap tokens the same token. A contract token's code differs + * between sources (the contract's symbol on a held balance, stellar.expert's + * code on a search row), so its contract ID alone identifies it. + */ +export const swapIdentityKey = (canonical: string) => + isContractToken(canonical) ? splitCanonical(canonical).issuer : canonical; + export const toBackendAsset = (canonical: string) => { if (isNativeAssetId(canonical)) { return "XLM"; } - const { issuer } = splitCanonical(canonical); - return StrKey.isValidContract(issuer) ? issuer : canonical; + return isContractToken(canonical) + ? splitCanonical(canonical).issuer + : canonical; +}; + +/** A held contract token's decimals; undefined for anything else. */ +export const getHeldTokenDecimals = ( + canonical: string, + balances: AssetType[], +): number | undefined => { + if (!isContractToken(canonical)) { + return undefined; + } + const held = findAssetBalance(balances, splitCanonical(canonical)); + return held && "decimals" in held ? Number(held.decimals) : undefined; }; const fromBackendAsset = (asset: string) => { @@ -151,6 +178,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 (isContractToken(sourceAsset) || isContractToken(destAsset)) { + return { route: null, quotes: null, key }; + } const record = mode === "send" ? await horizonGetBestPath({ diff --git a/extension/src/popup/helpers/trendingAssets.ts b/extension/src/popup/helpers/trendingAssets.ts index d6f2b44407..d7e06e7a67 100644 --- a/extension/src/popup/helpers/trendingAssets.ts +++ b/extension/src/popup/helpers/trendingAssets.ts @@ -1,7 +1,8 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { TrendingAsset } from "@shared/api/types"; import { getStellarExpertAssets } from "@shared/api/internal"; -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. 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,26 @@ 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 || + truncateString(record.asset), + 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 c40c72e045..e2faf4f170 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 f7f6637ff7..714621dc21 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,6 +1,6 @@ { + "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.", - "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", @@ -607,7 +607,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 f351230a53..73a2a762df 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,6 +1,6 @@ { + "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.", - "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", @@ -607,7 +607,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/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index a63753fb07..63ee9b900c 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -33,6 +33,7 @@ import { resetSimulation } from "popup/ducks/token-payment"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { publicKeySelector } from "popup/ducks/accountServices"; import { isAssetVisible } from "popup/helpers/settings"; +import { swapIdentityKey } from "popup/helpers/swapQuote"; import { readHiddenAssets } from "popup/helpers/useHiddenAssets"; import { getAssetFromCanonical } from "helpers/stellar"; import { isNativeAssetId } from "@shared/helpers/assetIdentity"; @@ -255,14 +256,19 @@ 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". - if (canonical === transactionData.asset) { + if ( + swapIdentityKey(canonical) === + swapIdentityKey(transactionData.asset) + ) { dispatch(saveAsset("")); + dispatch(saveIsToken(false)); dispatch(saveAmount("0")); dispatch(saveAmountUsd("0.00")); } @@ -319,7 +325,10 @@ export const Swap = () => { dispatch(saveAmountUsd("0.00")); // Can't swap a token for itself: if it matches the current // destination, reset the destination to "(+) Select". - if (canonical === transactionData.destinationAsset) { + if ( + swapIdentityKey(canonical) === + swapIdentityKey(transactionData.destinationAsset) + ) { dispatch(saveDestinationAsset("")); dispatch(saveDestinationTokenDetails(null)); } diff --git a/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx b/extension/src/popup/views/__tests__/Swap.selectionType.test.tsx index 4d00a3df44..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"; @@ -66,7 +67,6 @@ const resolvedFromState = { const emptyLookupResult = { sections: { yourTokens: [], popular: [], verified: [], unverified: [] }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }; @@ -280,7 +280,6 @@ describe("Swap selectionType wiring", () => { unverified: [], }, isSearch: false, - hadSorobanMatches: false, isFallback: false, }, error: null, @@ -368,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); + }); + }); }); 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, };