From e3434ceb958439bb7ee3e9e3e82f0bf6e8a3b031 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 6 Apr 2026 12:10:13 -0400 Subject: [PATCH] port contract id search back to master --- .../e2e-tests/addAssetContractSearch.test.ts | 151 ++++++++++++++++++ extension/e2e-tests/helpers/stubs.ts | 33 ++++ .../SearchAsset/hooks/useAssetLookup.ts | 41 +++-- .../__tests__/useSimulateSwapData.test.ts | 93 +++++++++++ .../SwapAmount/hooks/useSimulateSwapData.tsx | 45 ++++-- .../popup/helpers/__tests__/balance.test.js | 46 +++++- extension/src/popup/helpers/balance.ts | 6 + 7 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 extension/e2e-tests/addAssetContractSearch.test.ts create mode 100644 extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts diff --git a/extension/e2e-tests/addAssetContractSearch.test.ts b/extension/e2e-tests/addAssetContractSearch.test.ts new file mode 100644 index 0000000000..eb0846a47e --- /dev/null +++ b/extension/e2e-tests/addAssetContractSearch.test.ts @@ -0,0 +1,151 @@ +import { Page } from "@playwright/test"; +import { test, expect } from "./test-fixtures"; +import { loginToTestAccount } from "./helpers/login"; +import { + stubTokenDetails, + stubIsSac, + stubScanAssetSafe, + stubAssetSearchWithContractId, + stubAccountBalancesE2e, +} from "./helpers/stubs"; + +/** + * Helper to locate a ManageAssetRow by its exact asset code. + */ +const getAssetRow = (page: Page, code: string) => + page.getByTestId("ManageAssetRow").filter({ + has: page.getByTestId("ManageAssetCode").getByText(code, { exact: true }), + }); + +test("Stellar Expert contract ID result shows as already added", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: async () => { + await stubAssetSearchWithContractId(page); + await stubAccountBalancesE2e(page); + await stubTokenDetails(page); + await stubIsSac(page); + await stubScanAssetSafe(page); + }, + }); + + await page.getByTestId("account-options-dropdown").click(); + const manageAssets = page.getByText("Manage assets"); + await expect(manageAssets).toBeVisible(); + await manageAssets.click(); + + await expect(page.getByText("Your assets")).toBeVisible({ timeout: 10000 }); + await page.getByText("Add an asset").click({ force: true }); + + await page.getByTestId("search-asset-input").fill("E2E"); + + // Wait for search results to appear + const rows = page.getByTestId("ManageAssetRow"); + await expect(rows.first()).toBeVisible({ timeout: 10000 }); + + // The E2E token row should show the ellipsis menu instead of "Add" + // because the token is already in the user's balances + await expect( + page.getByTestId("ManageAssetRowButton__ellipsis-E2E"), + ).toBeVisible(); +}); + +test("Stellar Expert contract ID result shows Add when not owned", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: async () => { + await stubAssetSearchWithContractId(page); + await stubTokenDetails(page); + await stubIsSac(page); + await stubScanAssetSafe(page); + }, + }); + + await page.getByTestId("account-options-dropdown").click(); + const manageAssets = page.getByText("Manage assets"); + await expect(manageAssets).toBeVisible(); + await manageAssets.click(); + + await expect(page.getByText("Your assets")).toBeVisible({ timeout: 10000 }); + await page.getByText("Add an asset").click({ force: true }); + + await page.getByTestId("search-asset-input").fill("E2E"); + + // Wait for search results + const rows = page.getByTestId("ManageAssetRow"); + await expect(rows.first()).toBeVisible({ timeout: 10000 }); + + // Find the E2E token row by its exact asset code + const e2eRow = getAssetRow(page, "E2E"); + await expect(e2eRow).toBeVisible(); + + // The button should say "Add" since the user does not have this token + const rowButton = e2eRow.getByTestId("ManageAssetRowButton"); + await expect(rowButton).toHaveText("Add"); +}); + +test("Can add a token returned as contract ID from Stellar Expert search", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: async () => { + await stubAssetSearchWithContractId(page); + await stubTokenDetails(page); + await stubIsSac(page); + await stubScanAssetSafe(page); + }, + }); + + await page.getByTestId("account-options-dropdown").click(); + const manageAssets = page.getByText("Manage assets"); + await expect(manageAssets).toBeVisible(); + await manageAssets.click(); + + await expect(page.getByText("Your assets")).toBeVisible({ timeout: 10000 }); + await page.getByText("Add an asset").click({ force: true }); + + await page.getByTestId("search-asset-input").fill("E2E"); + + // Wait for search results + const rows = page.getByTestId("ManageAssetRow"); + await expect(rows.first()).toBeVisible({ timeout: 10000 }); + + // Find the E2E token row by its exact asset code and click Add + const e2eRow = getAssetRow(page, "E2E"); + await expect(e2eRow).toBeVisible(); + await e2eRow.getByTestId("ManageAssetRowButton").click(); + + // Should navigate to the Add Token confirmation page + await expect(page.getByTestId("ToggleToken__asset-code")).toHaveText( + "E2E Token", + ); + await expect(page.getByTestId("ToggleToken__asset-add-remove")).toHaveText( + "Add Token", + ); + + // Confirm the add + await page.getByRole("button", { name: "Confirm" }).click(); +}); diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 05091dc378..70109cd973 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -89,6 +89,39 @@ export const stubAssetSearch = async (page: Page) => { }); }; +export const stubAssetSearchWithContractId = async (page: Page) => { + await page.route("**/asset?search**", async (route) => { + const json = { + _embedded: { + records: [ + { + asset: + "USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + { + asset: TEST_TOKEN_ADDRESS, + code: "E2E", + token_name: "E2E Token", + decimals: 3, + domain: "example.com", + tomlInfo: { + code: "E2E", + // Use a different address than the token contract to match real + // Stellar Expert responses where tomlInfo.issuer is the token + // issuer, not the token contract itself. + issuer: + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + name: "E2E Token", + image: "", + }, + }, + ], + }, + }; + await route.fulfill({ json }); + }); +}; + export const stubHorizonAccounts = async (page: Page) => { await page.route("**/accounts/**", async (route) => { await route.fulfill({ diff --git a/extension/src/popup/components/manageAssets/SearchAsset/hooks/useAssetLookup.ts b/extension/src/popup/components/manageAssets/SearchAsset/hooks/useAssetLookup.ts index 26498f614c..fc691459ad 100644 --- a/extension/src/popup/components/manageAssets/SearchAsset/hooks/useAssetLookup.ts +++ b/extension/src/popup/components/manageAssets/SearchAsset/hooks/useAssetLookup.ts @@ -29,7 +29,15 @@ import { AppDispatch, store } from "popup/App"; interface AssetRecord { asset: string; domain?: string; - tomlInfo?: { image: string }; + code?: string; + token_name?: string; + decimals?: number; + tomlInfo?: { + image?: string; + code?: string; + issuer?: string; + name?: string; + }; } interface AssetLookupDetails { @@ -257,7 +265,8 @@ const useAssetLookup = () => { /* * Fetches data from Stellar Expert for the given asset. - * It returns an array of ManageAssetCurrency objects. + * Returns an array of ManageAssetCurrency objects for both classic + * ({code}-{issuer}) and contract ID results. * * @param {string} asset - The asset to look up. * @returns {Promise} @@ -282,13 +291,27 @@ const useAssetLookup = () => { throw new Error("Failed to fetch Stellar Expert data"); }); - return resJson._embedded.records.map((record: AssetRecord) => ({ - code: record.asset.split("-")[0], - issuer: record.asset.split("-")[1], - domain: record.domain, - image: record.tomlInfo?.image, - isSuspicious: false, - })); + return resJson._embedded.records.map((record: AssetRecord) => { + if (isContractId(record.asset)) { + return { + code: record.code || record.tomlInfo?.code || "", + issuer: record.asset, + contract: record.asset, + domain: record.domain ?? null, + image: record.tomlInfo?.image, + name: record.token_name || record.tomlInfo?.name, + decimals: record.decimals, + isSuspicious: false, + }; + } + return { + code: record.asset.split("-")[0], + issuer: record.asset.split("-")[1], + domain: record.domain ?? null, + image: record.tomlInfo?.image, + isSuspicious: false, + }; + }); }; /* 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 new file mode 100644 index 0000000000..0bcbb34ae1 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/hooks/__tests__/useSimulateSwapData.test.ts @@ -0,0 +1,93 @@ +import { getSwapErrorMessage, ERROR_TO_DISPLAY } from "../useSimulateSwapData"; + +const CONTRACT_ID = "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7"; +const CLASSIC_ISSUER = + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +const classicAsset = { issuer: CLASSIC_ISSUER }; +const contractAsset = { issuer: CONTRACT_ID }; + +describe("getSwapErrorMessage", () => { + it("returns custom token error when source is a contract ID", () => { + const result = getSwapErrorMessage( + new Error("some error"), + contractAsset, + classicAsset, + ); + expect(result).toBe(ERROR_TO_DISPLAY.CUSTOM_TOKEN_NOT_SUPPORTED); + }); + + it("returns custom token error when dest is a contract ID", () => { + const result = getSwapErrorMessage( + new Error("some error"), + classicAsset, + contractAsset, + ); + expect(result).toBe(ERROR_TO_DISPLAY.CUSTOM_TOKEN_NOT_SUPPORTED); + }); + + it("returns custom token error when both are contract IDs", () => { + const result = getSwapErrorMessage( + new Error("some error"), + contractAsset, + contractAsset, + ); + expect(result).toBe(ERROR_TO_DISPLAY.CUSTOM_TOKEN_NOT_SUPPORTED); + }); + + it("returns known error even when assets are contract IDs", () => { + const result = getSwapErrorMessage( + new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND), + contractAsset, + classicAsset, + ); + expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); + }); + + it("returns known error message for classic assets", () => { + const result = getSwapErrorMessage( + new Error(ERROR_TO_DISPLAY.NO_PATH_FOUND), + classicAsset, + classicAsset, + ); + expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); + }); + + it("returns unknown error for unrecognized Error with classic assets", () => { + const result = getSwapErrorMessage( + new Error("something unexpected"), + classicAsset, + classicAsset, + ); + expect(result).toBe( + "We had an issue retrieving your transaction details. Please try again.", + ); + }); + + it("returns known error message for string errors with classic assets", () => { + const result = getSwapErrorMessage( + ERROR_TO_DISPLAY.NO_PATH_FOUND, + classicAsset, + classicAsset, + ); + expect(result).toBe(ERROR_TO_DISPLAY.NO_PATH_FOUND); + }); + + it("returns unknown error for unrecognized string with classic assets", () => { + const result = getSwapErrorMessage( + "something unexpected", + classicAsset, + classicAsset, + ); + 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); + 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 2ab03fa3ea..5ea0d1afcd 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSimulateSwapData.tsx @@ -29,6 +29,7 @@ import { import { useScanTx } from "popup/helpers/blockaid"; import { BlockAidScanTxResult } from "@shared/api/types"; import { horizonGetBestPath } from "popup/helpers/horizonGetBestPath"; +import { isContractId } from "popup/helpers/soroban"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; import { AppDispatch } from "popup/App"; @@ -36,6 +37,32 @@ const scanUrlstub = "internal"; export const ERROR_TO_DISPLAY = { NO_PATH_FOUND: "No path found for swap.", + CUSTOM_TOKEN_NOT_SUPPORTED: "Swapping custom tokens is not supported yet.", +}; + +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 + const errorStr = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : null; + 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; }; interface SimulationParams { @@ -232,22 +259,8 @@ function useSimulateTxData({ dispatch({ type: "FETCH_DATA_SUCCESS", payload }); return payload; } catch (error) { - const unknownErrorDisplay = - "We had an issue retrieving your transaction details. Please try again."; - let payload: string; - - if (error instanceof Error) { - // If the error message matches one of our known display errors, use it - payload = Object.values(ERROR_TO_DISPLAY).includes(error.message) - ? error.message - : unknownErrorDisplay; - } else if (typeof error === "string") { - payload = Object.values(ERROR_TO_DISPLAY).includes(error) - ? error - : unknownErrorDisplay; - } else { - payload = unknownErrorDisplay; - } + const { sourceAsset, destAsset } = simParams; + const payload = getSwapErrorMessage(error, sourceAsset, destAsset); dispatch({ type: "FETCH_DATA_ERROR", payload }); return error; diff --git a/extension/src/popup/helpers/__tests__/balance.test.js b/extension/src/popup/helpers/__tests__/balance.test.js index e0115c230d..1859ac686b 100644 --- a/extension/src/popup/helpers/__tests__/balance.test.js +++ b/extension/src/popup/helpers/__tests__/balance.test.js @@ -3,7 +3,7 @@ import BigNumber from "bignumber.js"; import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; -import { getBalanceByKey } from "../balance"; +import { getBalanceByKey, findAssetBalance } from "../balance"; const CONTRACT_ID = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; const TOKEN_BALANCE_KEY = `DT:${CONTRACT_ID}`; @@ -56,3 +56,47 @@ describe("getBalanceByKey", () => { expect(balance).toEqual(TOKEN_BALANCE); }); }); + +describe("findAssetBalance", () => { + it("should match a classic asset by code and issuer", () => { + const result = findAssetBalance(BALANCES, { + code: "USDC", + issuer: "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + }); + expect(result).toBeDefined(); + expect(result.token.code).toBe("USDC"); + }); + + it("should match a Soroban token when issuer is a contract ID", () => { + const result = findAssetBalance(BALANCES, { + code: "DT", + issuer: CONTRACT_ID, + }); + expect(result).toBeDefined(); + expect(result.contractId).toBe(CONTRACT_ID); + }); + + it("should return undefined when contract ID is not in balances", () => { + const result = findAssetBalance(BALANCES, { + code: "NOPE", + issuer: "CAZXEHTSQATVQVWDPWWDTFSY6CM764JD4MZ6HUVPO3QKS64QEEP4KJH7", + }); + expect(result).toBeUndefined(); + }); + + it("should return undefined for an empty balances array", () => { + const result = findAssetBalance([], { + code: "DT", + issuer: CONTRACT_ID, + }); + expect(result).toBeUndefined(); + }); + + it("should not match a classic asset when issuer does not match", () => { + const result = findAssetBalance(BALANCES, { + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }); + expect(result).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/helpers/balance.ts b/extension/src/popup/helpers/balance.ts index 5e355d81b0..fc440af0ab 100644 --- a/extension/src/popup/helpers/balance.ts +++ b/extension/src/popup/helpers/balance.ts @@ -42,6 +42,12 @@ export const findAssetBalance = ( balance.token.type === "native", ) as Exclude | undefined; } + if (isContractId(asset.issuer)) { + return balances.find( + (balance) => + "contractId" in balance && balance.contractId === asset.issuer, + ) as Exclude | undefined; + } return balances.find((balance) => { const balanceIssuer = "token" in balance && "issuer" in balance.token