diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 76762a49f1..8d7f96ec80 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -651,6 +651,42 @@ export const stubIsSac = async (page: Page | BrowserContext) => { }); }; +/** + * SAC variant of stubIsSac — returns isSacContract: true so the AddToken view + * routes through the ChangeTrustInternal review instead of silently resolving. + * Use together with stubSacTokenDetails so token-details returns a "CODE:G…" + * name that satisfies StrKey.isValidEd25519PublicKey(assetIssuer). + */ +export const stubIsSacTrue = async (page: Page | BrowserContext) => { + await page.route("**/is-sac-contract**", async (route) => { + const json = { + isSacContract: true, + }; + await route.fulfill({ json }); + }); +}; + +// The G-address used across the e2e suite as the SAC issuer. +export const SAC_ISSUER = + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY"; + +/** + * Stubs token-details so the SAC token's name is "E2E:". + * useTokenLookup splits on ":" to extract the issuer, and AddToken checks + * StrKey.isValidEd25519PublicKey(assetIssuer) to gate the SAC review branch. + */ +export const stubSacTokenDetails = async (page: Page | BrowserContext) => { + await page.route("**/token-details/**", async (route) => { + await route.fulfill({ + json: { + name: `E2E:${SAC_ISSUER}`, + decimals: 7, + symbol: "E2E", + }, + }); + }); +}; + export const stubTokenDetails = async (page: Page | BrowserContext) => { await page.route("**/token-details/**", async (route) => { const url = route.request().url(); @@ -3146,3 +3182,40 @@ export const stubMaintenanceBannerVariant = async ( }, }); }; + +/** + * Stubs the asset-list endpoint so that the given contractId appears as a + * verified token. This causes getVerifiedTokens() to return a non-empty array, + * which sets isVerifiedToken=true in AddToken and suppresses the + * "Not on your lists" AssetListWarning banner. + * + * Use page.route() (popup-level) for SEP-41 tokens and context.addInitScript() + * for the SAC flow (where the popup is created before page.route() can fire). + * + * @param page - Playwright Page or BrowserContext to attach the route to + * @param contractId - The contract address that should appear as verified + */ +export const stubVerifiedToken = async ( + page: Page | BrowserContext, + contractId: string, +) => { + const verifiedAssetList = { + ...STELLAR_EXPERT_ASSET_LIST_JSON, + assets: [ + ...STELLAR_EXPERT_ASSET_LIST_JSON.assets, + { + code: "E2E", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + contract: contractId, + name: "E2E Token", + org: "unknown", + domain: "example.com", + decimals: 7, + }, + ], + }; + + await page.route("*/**/testnet/asset-list/**", async (route) => { + await route.fulfill({ json: verifiedAssetList }); + }); +}; diff --git a/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts b/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts index 6ef3c9b46d..5852fc610d 100644 --- a/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts +++ b/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts @@ -3,12 +3,21 @@ import { TEST_TOKEN_ADDRESS } from "../helpers/test-token"; import { loginToTestAccount, switchToMainnet } from "../helpers/login"; import { allowDapp } from "../helpers/dAppSessionHelper"; import { + SAC_ISSUER, stubAccountBalances, stubAccountHistory, + stubBackendSubmitTx, + stubFeeStats, + stubHorizonAccounts, stubIsSac, + stubIsSacTrue, + stubSacTokenDetails, + stubScanAssetSafe, stubScanDapp, + stubScanTx, stubTokenDetails, stubTokenPrices, + stubVerifiedToken, } from "../helpers/stubs"; const TX_TO_SIGN = @@ -807,7 +816,7 @@ test("should not sign message when not allowed", async ({ await expect(popup.getByText("Connection Request")).toBeVisible(); }); -test("should add token when allowed", async ({ +test("should add an unverified SEP-41 token when allowed", async ({ page, extensionId, context, @@ -835,6 +844,7 @@ test("should add token when allowed", async ({ const popup = await popupPromise; await expect(popup.getByText("E2E Token")).toBeDefined(); + await expect(popup.getByText("Not on your lists")).toBeVisible(); await expectPageToHaveScreenshot({ page: popup, screenshot: "add-token.png", @@ -846,7 +856,146 @@ test("should add token when allowed", async ({ ); }); -test("should not add token when not allowed", async ({ +// The cryptographically-derived SAC contract for E2E:SAC_ISSUER on testnet. +// new Asset("E2E", SAC_ISSUER).contractId("Test SDF Network ; September 2015") +// Using this contract (not TEST_TOKEN_ADDRESS) is required so that +// isAssetSac() in useGetChangeTrustData verifies correctly and builds the XDR. +const SAC_CONTRACT_ID = + "CAMGWOMKYNKCWGHXTU6A7OYW3O6O4UFMHSMQDSIA2WSD6M6U6GSAJASN"; + +// SAC token test: skipped in integration mode — the stubs needed to classify +// SAC_CONTRACT_ID as a SAC must be injected before the popup opens, which +// requires the window.fetch addInitScript approach described below. In +// integration mode all stubs are bypassed and the real backend is used, but +// the test account does not have a live SAC on testnet. +test("should add an unverified SAC token through the Change Trust review when allowed", async ({ + page, + extensionId, + context, +}) => { + test.skip( + isIntegrationMode, + "SAC stub injection via addInitScript is not compatible with integration mode", + ); + + // Playwright's context.route() / page.route() does not reliably intercept + // fetch calls made by Chrome extension popup pages in headless mode because + // those pages run in an isolated extension process. We therefore override + // window.fetch via context.addInitScript(), which is guaranteed to run before + // ANY page script on every new page — including the popup opened by + // browser.windows.create(). This is the same technique used in + // stubAccountHistoryWith (page level) applied at context level. + // + // Endpoints overridden: + // is-sac-contract → { isSacContract: true } + // token-details → { name: "E2E:", symbol: "E2E", decimals: 7 } + // + // Effect on the AddToken flow: + // useTokenLookup receives isSacContract=true → sets issuer to the G-address + // → StrKey.isValidEd25519PublicKey(issuer) = true → isSac = true + // → Confirm click calls setShowTrustlineReview(true) instead of handleApprove() + // → ChangeTrustInternal renders, isAssetSac verifies SAC_CONTRACT_ID, builds XDR + await context.addInitScript( + ({ sacIssuer }: { sacIssuer: string }) => { + const origFetch = (window as Window & typeof globalThis).fetch.bind( + window, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).fetch = function (input: any, init: any) { + const url: string = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input?.url ?? ""); + if (url.includes("/is-sac-contract/")) { + return Promise.resolve( + new Response(JSON.stringify({ isSacContract: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (url.includes("/token-details/")) { + return Promise.resolve( + new Response( + JSON.stringify({ + name: `E2E:${sacIssuer}`, + symbol: "E2E", + decimals: 7, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + return origFetch(input, init); + }; + }, + { sacIssuer: SAC_ISSUER }, + ); + + // context.route() as belt-and-suspenders (intercepted when CDP cooperates) + await stubIsSacTrue(context); + await stubSacTokenDetails(context); + + await loginToTestAccount({ page, extensionId, context, isIntegrationMode }); + await allowDapp({ page }); + + // Open a second tab pointing at the addToken playground. + const pageTwo = await page.context().newPage(); + await pageTwo.waitForLoadState(); + + const popupPromise = page.context().waitForEvent("page"); + await pageTwo.goto( + "https://play.freighter.app/#/extension/playground/addToken", + ); + // Use the cryptographically-derived SAC contract ID, not TEST_TOKEN_ADDRESS. + // AddToken passes this contractId to useTokenLookup and then to ChangeTrustInternal; + // isAssetSac() verifies it against Asset("E2E", SAC_ISSUER).contractId(passphrase). + await pageTwo.getByRole("textbox").first().fill(SAC_CONTRACT_ID); + await pageTwo + .getByRole("textbox") + .nth(1) + .fill("Test SDF Network ; September 2015"); + await pageTwo.getByText("Add Token").click(); + + const popup = await popupPromise; + + // Belt-and-suspenders page-level stubs on the popup for requests fired after + // the popup is captured (ChangeTrust data-loading phase). + await stubIsSacTrue(popup); + await stubSacTokenDetails(popup); + await stubFeeStats(popup); + await stubHorizonAccounts(popup); + await stubScanAssetSafe(popup); + await stubScanTx(popup); + await stubBackendSubmitTx(popup); + await stubAccountBalances(popup); + + // Wait for the token lookup to resolve (the Confirm button is hidden while + // isLoading). For a SAC token the Add Token screen shows the Fee and Token + // address rows (per the Figma design) before the trustline review. + await expect(popup.getByTestId("AddToken__Metadata__Row__Fee")).toBeVisible({ + timeout: 15000, + }); + await expect( + popup.getByTestId("AddToken__Metadata__Row__TokenAddress"), + ).toBeVisible(); + await expect(popup.getByText("Not on your lists")).toBeVisible(); + + // Clicking confirm for a SAC slides in the standard changeTrust review. + await popup.getByTestId("add-token-approve").click(); + + await expect(popup.getByTestId("ChangeTrustInternal__Body")).toBeVisible({ + timeout: 15000, + }); + await expect(popup.getByText("Add Trustline")).toBeVisible(); +}); + +test("should not add a SEP-41 token when the domain is not allowed", async ({ page, extensionId, context, @@ -886,6 +1035,197 @@ test("should not add token when not allowed", async ({ }); }); +test("should add a verified SEP-41 token without the unverified banner", async ({ + page, + extensionId, + context, +}) => { + await stubIsSac(context); + // Mark verified (no "Not on your lists" banner). On `context` so it's set + // before the popup's initial asset-list fetch. + await stubVerifiedToken(context, TEST_TOKEN_ADDRESS); + + await loginToTestAccount({ page, extensionId, context, isIntegrationMode }); + await allowDapp({ page }); + + // open a second tab and go to docs playground + const pageTwo = await page.context().newPage(); + await pageTwo.waitForLoadState(); + + const popupPromise = page.context().waitForEvent("page"); + await pageTwo.goto( + "https://play.freighter.app/#/extension/playground/addToken", + ); + await pageTwo.getByRole("textbox").first().fill(TEST_TOKEN_ADDRESS); + await pageTwo + .getByRole("textbox") + .nth(1) + .fill("Test SDF Network ; September 2015"); + await pageTwo.getByText("Add Token").click(); + + const popup = await popupPromise; + + // Also stub the asset-list on the popup page itself (belt-and-suspenders for + // requests fired after the popup is captured). + await stubVerifiedToken(popup, TEST_TOKEN_ADDRESS); + + await expect(popup.getByText("E2E Token")).toBeDefined(); + // Verified token: the "Not on your lists" banner must NOT be shown. + await expect(popup.getByText("Not on your lists")).toHaveCount(0); + await popup.getByTestId("add-token-approve").click(); + + await expect(pageTwo.locator("#result-addToken")).toContainText( + "Token added:", + ); +}); + +// SAC verified token test: skipped in integration mode for the same reasons as the +// unverified SAC test — stub injection via addInitScript is not compatible with +// integration mode (real backend is used and there is no live SAC on testnet). +test("should add a verified SAC token through the Change Trust review without the unverified banner", async ({ + page, + extensionId, + context, +}) => { + test.skip( + isIntegrationMode, + "SAC stub injection via addInitScript is not compatible with integration mode", + ); + + // Inject the SAC stubs (is-sac-contract, token-details) AND the asset-list + // via window.fetch override so they fire before the popup page script runs. + // This is the same technique used by the unverified SAC test for is-sac-contract + // and token-details; here we extend it to also cover the asset-list fetch so + // that getVerifiedTokens() finds SAC_CONTRACT_ID and sets isVerifiedToken=true. + await context.addInitScript( + ({ + sacIssuer, + sacContractId, + }: { + sacIssuer: string; + sacContractId: string; + }) => { + const origFetch = (window as Window & typeof globalThis).fetch.bind( + window, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).fetch = function (input: any, init: any) { + const url: string = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input?.url ?? ""); + if (url.includes("/is-sac-contract/")) { + return Promise.resolve( + new Response(JSON.stringify({ isSacContract: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (url.includes("/token-details/")) { + return Promise.resolve( + new Response( + JSON.stringify({ + name: `E2E:${sacIssuer}`, + symbol: "E2E", + decimals: 7, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + if (url.includes("/asset-list/")) { + return Promise.resolve( + new Response( + JSON.stringify({ + name: "StellarExpert Top 50", + provider: "StellarExpert", + description: "Verified asset list", + version: "1.0", + network: "testnet", + feedback: "https://stellar.expert", + assets: [ + { + code: "E2E", + issuer: sacIssuer, + contract: sacContractId, + name: "E2E Token", + org: "unknown", + domain: "example.com", + decimals: 7, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + return origFetch(input, init); + }; + }, + { sacIssuer: SAC_ISSUER, sacContractId: SAC_CONTRACT_ID }, + ); + + // context.route() as belt-and-suspenders + await stubIsSacTrue(context); + await stubSacTokenDetails(context); + await stubVerifiedToken(context, SAC_CONTRACT_ID); + + await loginToTestAccount({ page, extensionId, context, isIntegrationMode }); + await allowDapp({ page }); + + const pageTwo = await page.context().newPage(); + await pageTwo.waitForLoadState(); + + const popupPromise = page.context().waitForEvent("page"); + await pageTwo.goto( + "https://play.freighter.app/#/extension/playground/addToken", + ); + await pageTwo.getByRole("textbox").first().fill(SAC_CONTRACT_ID); + await pageTwo + .getByRole("textbox") + .nth(1) + .fill("Test SDF Network ; September 2015"); + await pageTwo.getByText("Add Token").click(); + + const popup = await popupPromise; + + // Belt-and-suspenders page-level stubs on the popup. + await stubIsSacTrue(popup); + await stubSacTokenDetails(popup); + await stubVerifiedToken(popup, SAC_CONTRACT_ID); + await stubFeeStats(popup); + await stubHorizonAccounts(popup); + await stubScanAssetSafe(popup); + await stubScanTx(popup); + await stubBackendSubmitTx(popup); + await stubAccountBalances(popup); + + await expect(popup.getByTestId("AddToken__Metadata__Row__Fee")).toBeVisible({ + timeout: 15000, + }); + await expect( + popup.getByTestId("AddToken__Metadata__Row__TokenAddress"), + ).toBeVisible(); + // Verified SAC token: the "Not on your lists" banner must NOT be shown. + await expect(popup.getByText("Not on your lists")).toHaveCount(0); + + await popup.getByTestId("add-token-approve").click(); + + await expect(popup.getByTestId("ChangeTrustInternal__Body")).toBeVisible({ + timeout: 15000, + }); + await expect(popup.getByText("Add Trustline")).toBeVisible(); +}); + test("should get public key when logged out", async ({ page, extensionId, diff --git a/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts-snapshots/add-token-chromium-darwin.png b/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts-snapshots/add-token-chromium-darwin.png index b1d0d9db13..1890e0ecd2 100644 Binary files a/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts-snapshots/add-token-chromium-darwin.png and b/extension/e2e-tests/integration-tests/freighterApiIntegration.test.ts-snapshots/add-token-chromium-darwin.png differ diff --git a/extension/src/popup/components/WarningMessages/index.tsx b/extension/src/popup/components/WarningMessages/index.tsx index 61f0f2fda8..6ba27a4797 100644 --- a/extension/src/popup/components/WarningMessages/index.tsx +++ b/extension/src/popup/components/WarningMessages/index.tsx @@ -207,10 +207,10 @@ export const AssetListWarning = ({ const { t } = useTranslation(); const title = isVerified ? t("On your lists") : t("Not on your lists"); return ( -
+
- +

{title}

diff --git a/extension/src/popup/components/WarningMessages/styles.scss b/extension/src/popup/components/WarningMessages/styles.scss index 9617e8c054..316ed24f10 100644 --- a/extension/src/popup/components/WarningMessages/styles.scss +++ b/extension/src/popup/components/WarningMessages/styles.scss @@ -603,6 +603,26 @@ } } +// "Not on your lists" banner — brand lilac, distinct from the amber ScanMiss. +// Sizing/spacing inherit from .ScanLabel to stay consistent with the other +// banners (the mobile Figma's smaller type scale doesn't apply to extension). +.ScanAssetList { + color: var(--sds-clr-lilac-11); + background-color: var(--sds-clr-lilac-03); + + .Icon .WarningMessage__icon { + color: var(--sds-clr-lilac-09); + } + + .Message { + font-weight: var(--font-weight-medium); + } + + .ScanLabel__Action svg { + color: var(--sds-clr-lilac-11); + } +} + .BlockaidWarningModal { height: 100vh; width: 100vw; diff --git a/extension/src/popup/components/__tests__/ChangeTrustInternal.test.tsx b/extension/src/popup/components/__tests__/ChangeTrustInternal.test.tsx new file mode 100644 index 0000000000..6f25af7b68 --- /dev/null +++ b/extension/src/popup/components/__tests__/ChangeTrustInternal.test.tsx @@ -0,0 +1,313 @@ +import React from "react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; + +import { ChangeTrustInternal } from "popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal"; +import { Wrapper, mockBalances } from "popup/__testHelpers__"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; + +import * as GetManageAssetXDR from "popup/helpers/getManageAssetXDR"; +import * as ApiInternal from "@shared/api/internal"; +import * as CheckForSuspiciousAsset from "popup/helpers/checkForSuspiciousAsset"; +import * as BlockaidHelpers from "popup/helpers/blockaid"; +import * as StellarSdkServer from "@shared/api/helpers/stellarSdkServer"; + +const TEST_TX = { + signatureBase: () => "signatureBase", + toXDR: () => "toXDR", + _networkPassphrase: "Test SDF Network ; September 2015", + _tx: {}, + signatures: [], + fee: "100", + _envelopeType: { name: "envelopeTypeTx", value: 2 }, + _memo: {}, + _sequence: "2457228099452961", + _source: "GBKWMR7TJ7BBICOOXRY2SWXKCWPTOHZPI6MP4LNNE5A73VP3WADGG3CH", + _timeBounds: { minTime: "0", maxTime: "0" }, + operations: [ + { + type: "changeTrust", + line: { + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + limit: "922337203685.4775807", + }, + ], +}; + +jest.mock("stellar-sdk", () => { + const original = jest.requireActual("stellar-sdk"); + return { + Asset: original.Asset, + BASE_FEE: original.BASE_FEE, + Operation: original.Operation, + Networks: original.Networks, + Horizon: original.Horizon, + rpc: original.rpc, + StrKey: { + ...original.StrKey, + encodeEd25519PublicKey: () => + "GBKWMR7TJ7BBICOOXRY2SWXKCWPTOHZPI6MP4LNNE5A73VP3WADGG3CH", + }, + TransactionBuilder: { + fromXDR: () => TEST_TX as any, + }, + Keypair: { + fromPublicKey: () => ({ + signatureHint: () => "signatureHint", + }), + }, + xdr: { + DecoratedSignature: original.xdr.DecoratedSignature, + }, + }; +}); + +jest.mock("@ledgerhq/hw-transport-webhid", () => { + const original = jest.requireActual("@ledgerhq/hw-transport-webhid"); + return { + ...original, + create: () => Promise.resolve({ close: () => Promise.resolve() }), + list: () => Promise.resolve([{ close: () => Promise.resolve() }]), + request: () => Promise.resolve({ close: () => Promise.resolve() }), + }; +}); + +jest.mock("@ledgerhq/hw-app-str", () => { + return jest.fn().mockImplementation(() => { + return { + getPublicKey: () => + Promise.resolve({ + rawPublicKey: jest.fn(), + }), + signTransaction: () => Promise.resolve({ signature: "L1" }), + }; + }); +}); + +// Ledger account matching keys used by ManageAssetRows tests +const LEDGER_PUBLIC_KEY = + "GBKWMR7TJ7BBICOOXRY2SWXKCWPTOHZPI6MP4LNNE5A73VP3WADGG3CH"; + +const PRELOADED_STATE = { + auth: { + hasPrivateKey: true, + allAccounts: [ + { + hardwareWalletType: "", + imported: false, + name: "Account 1", + publicKey: "G1", + }, + { + hardwareWalletType: "", + imported: true, + name: "Account 2", + publicKey: "G2", + }, + { + hardwareWalletType: "Ledger", + imported: true, + name: "Ledger 1", + publicKey: LEDGER_PUBLIC_KEY, + }, + ], + publicKey: LEDGER_PUBLIC_KEY, + }, + settings: { + networkDetails: TESTNET_NETWORK_DETAILS, + }, +}; + +const ASSET = { + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + image: null, + domain: "centre.io", + contract: "", +}; + +const renderComponent = (props: Record = {}) => + render( + + + , + ); + +describe("ChangeTrustInternal", () => { + jest + .spyOn(GetManageAssetXDR, "getManageAssetXDR") + .mockImplementation(() => + Promise.resolve( + "AAAAAgAAAABVZkfzT8IUCc68cala6hWfNx8vR5j+La0nQf3V+7AGYwAAAGQACLrWAAAAIQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layf/////////8AAAAAAAAAAA==", + ), + ); + + jest + .spyOn(CheckForSuspiciousAsset, "checkForSuspiciousAsset") + .mockImplementation(() => + Promise.resolve({ isRevocable: false, isInvalidDomain: false }), + ); + + jest.spyOn(BlockaidHelpers, "scanAsset").mockImplementation(() => + Promise.resolve({ + result_type: "Benign", + address: "", + chain: "stellar", + attack_types: {}, + fees: {}, + } as any), + ); + + jest.spyOn(StellarSdkServer, "stellarSdkServer").mockImplementation( + () => + ({ + accounts: { + accountId: () => ({ + call: () => Promise.resolve({ balances: [] }), + }), + }, + }) as any, + ); + + jest + .spyOn(ApiInternal, "getAccountBalances") + .mockImplementation(() => Promise.resolve(mockBalances)); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("calls onSuccess (not onCancel) after a successful submit", async () => { + const onSuccess = jest.fn(); + const onCancel = jest.fn(); + + jest.spyOn(global, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: async () => ({ + envelope_xdr: + "AAAAAgAAAABngBTmbmUycqG2cAMHcomSR80dRzGtKzxM6gb3yySD5AAPQkAAAYjdAAAA9gAAAAEAAAAAAAAAAAAAAABmXjffAAAAAAAAAAEAAAAAAAAABgAAAAFVU0RDAAAAACYFzNOyHT8GgwiyzcOOhwLtCctwM/RiSnrFp7JOe8xeAAAAAAAAAAAAAAAAAAAAAcskg+QAAABAA/rRMU+KKsxCX1pDBuCvYDz+eQTCsY9bzgPU4J+Xe3vOWUa8YOzWlL3N3zlxHVx9hsB7a8dpSXMSAINjjsY4Dg==", + hash: "hash", + successful: true, + }), + } as any), + ); + + jest.spyOn(ApiInternal, "getHiddenAssets").mockImplementation(() => + Promise.resolve({ + hiddenAssets: {}, + error: "", + }), + ); + + renderComponent({ onSuccess, onCancel }); + + await waitFor(() => + expect( + screen.getByTestId("ChangeTrustInternal__Body"), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByText("Confirm")); + + await waitFor(() => + expect(screen.getByTestId("SubmitTransaction__Title")).toHaveTextContent( + "Submitting", + ), + ); + + await waitFor(() => + expect( + screen.getByTestId("SubmitTransaction__Title__Success"), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByText("Done")); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("calls onCancel on Done when onSuccess is not provided (backward-compatible default)", async () => { + const onCancel = jest.fn(); + + jest.spyOn(global, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: async () => ({ + envelope_xdr: "AAAAAgAAAA==", + hash: "hash", + successful: true, + }), + } as any), + ); + + jest.spyOn(ApiInternal, "getHiddenAssets").mockImplementation(() => + Promise.resolve({ + hiddenAssets: {}, + error: "", + }), + ); + + renderComponent({ onCancel }); + + await waitFor(() => + expect( + screen.getByTestId("ChangeTrustInternal__Body"), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByText("Confirm")); + + await waitFor(() => + expect( + screen.getByTestId("SubmitTransaction__Title__Success"), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByText("Done")); + + await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1)); + }); + + it("defaults the fee to the base fee in XLM when no initialFee is given", async () => { + renderComponent(); + + await waitFor(() => + expect( + screen.getByTestId("ChangeTrustInternal__Body"), + ).toBeInTheDocument(), + ); + + const feeValue = screen.getByTestId( + "ChangeTrustInternal__Metadata__Value__Fee", + ); + // baseFeeStroops = stroopToXlm(BASE_FEE) = "0.00001" + expect(feeValue).toHaveTextContent("0.00001 XLM"); + }); + + it("displays the provided initialFee (the disclosed fee == the charged fee)", async () => { + renderComponent({ initialFee: "0.0011234" }); + + await waitFor(() => + expect( + screen.getByTestId("ChangeTrustInternal__Body"), + ).toBeInTheDocument(), + ); + + const feeValue = screen.getByTestId( + "ChangeTrustInternal__Metadata__Value__Fee", + ); + expect(feeValue).toHaveTextContent("0.0011234 XLM"); + // Guards the old unit bug: a stroops-denominated value rendered as XLM. + expect(feeValue).not.toHaveTextContent("100 XLM"); + }); +}); diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx index bb34f54b2f..67fe0e8ae9 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx @@ -48,6 +48,11 @@ interface SubmitTransactionProps { icons: AssetIcons; goBack: () => void; onSuccess: () => void; + onClose: () => void; + // Hide the "close this tab" hint shown during submit: it nudges users to + // close and leave the dApp's addToken promise hanging. The transaction itself + // still succeeds even if they close. + hideCloseTabHint?: boolean; } export const SubmitTransaction = ({ @@ -57,6 +62,8 @@ export const SubmitTransaction = ({ fee, goBack, onSuccess, + onClose, + hideCloseTabHint = false, }: SubmitTransactionProps) => { const { t } = useTranslation(); const dispatch: AppDispatch = useDispatch(); @@ -175,7 +182,7 @@ export const SubmitTransaction = ({ - {isLoading && ( + {isLoading && !hideCloseTabHint && ( <>
{t( @@ -229,7 +236,11 @@ export const SubmitTransaction = ({ isHardwareWallet, isSuccess, }); - onSuccess(); + if (isSuccess) { + onSuccess(); + } else { + onClose(); + } }} > {t("Done")} diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx index c29c86bd7f..cc8e38ff46 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx @@ -122,7 +122,18 @@ function useGetChangeTrustData({ useEffect(() => { fetchData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [ + addTrustline, + asset.code, + asset.contract, + asset.domain, + asset.issuer, + blockaidOverrideState, + networkDetails.networkPassphrase, + networkDetails.networkUrl, + publicKey, + recommendedFee, + ]); return { state, diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/index.tsx index 4df5cf6f89..87cc0cb071 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/index.tsx @@ -53,6 +53,18 @@ interface ChangeTrustInternalProps { publicKey: string; addTrustline: boolean; onCancel: () => void; + onSuccess?: () => void; + // Dismiss after a FAILED submit. Defaults to onCancel; the external Add Token + // flow passes a handler that rejects the dApp request so it doesn't hang. + onClose?: () => void; + // Initial fee (in XLM) for the transaction. The external Add Token (SAC) + // flow passes the network-recommended fee it already displayed so the + // charged fee matches the disclosed one. Defaults to the base fee. + initialFee?: string; + // When rendered as a full popup view (the external Add Token flow) rather + // than inside the content-sized in-app modal, fill the available height so + // the action buttons / submit footer pin to the bottom instead of floating. + isFullHeight?: boolean; } export const ChangeTrustInternal = ({ @@ -61,7 +73,14 @@ export const ChangeTrustInternal = ({ publicKey, networkDetails, onCancel, + onSuccess, + onClose, + initialFee, + isFullHeight = false, }: ChangeTrustInternalProps) => { + const rootClassName = `ChangeTrustInternal${ + isFullHeight ? " ChangeTrustInternal--standalone" : "" + }`; const activeOptionsRef = useRef(null); const [activePaneIndex, setActivePaneIndex] = useState(0); const [activeBodyContent, setActiveBodyContent] = useState( @@ -74,7 +93,7 @@ export const ChangeTrustInternal = ({ const { recommendedFee } = useNetworkFees(); const baseFeeStroops = stroopToXlm(BASE_FEE).toString(); - const [fee, setFee] = useState(baseFeeStroops); + const [fee, setFee] = useState(initialFee ?? baseFeeStroops); const [timeout, setTimeout] = useState("180"); const [memo, setMemo] = useState(""); const [isSettingsSelectorOpen, setSettingsSelectorOpen] = @@ -86,7 +105,7 @@ export const ChangeTrustInternal = ({ addTrustline, publicKey, networkDetails, - recommendedFee: BASE_FEE, + recommendedFee: fee, }); useEffect(() => { @@ -110,7 +129,7 @@ export const ChangeTrustInternal = ({ state.state === RequestState.IDLE ) { return ( -
+
@@ -120,7 +139,7 @@ export const ChangeTrustInternal = ({ if (state.state === RequestState.ERROR) { return ( -
+
); + // The submit/success screen (SubmitTransaction) renders its own padded + // View.Content, so make this outer one transparent in that state to avoid + // double padding (which left the footer buttons misaligned vs the content). + const isSubmitting = activeBodyContent === ActiveBodyContent.submitTx; + return ( - -
+ +
{activeBodyContent === ActiveBodyContent.details && ( <> @@ -518,7 +542,9 @@ export const ChangeTrustInternal = ({ icons={icons} fee={fee} goBack={() => setActiveBodyContent(ActiveBodyContent.details)} - onSuccess={onCancel} + onSuccess={onSuccess ?? onCancel} + onClose={onClose ?? onCancel} + hideCloseTabHint={isFullHeight} /> )}
diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/styles.scss b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/styles.scss index 456d8c1f57..3130aa1e77 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/styles.scss +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/styles.scss @@ -10,6 +10,75 @@ background-color: var(--sds-clr-gray-01); border-radius: 0.875rem; + // Full popup view (external Add Token), vs the content-sized in-app modal: + // fill the height so actions/footer pin to the bottom and the scroll region + // expands to occupy all the space down to the buttons (no gray gap). Use a + // hard height (not min-height) so the inner Body is firmly bounded and + // becomes the scroll container instead of staying content-sized. + &--standalone { + flex: 1; + height: 100%; + min-height: 0; + overflow: hidden; + + // Grow the slider so the action row is pushed to the bottom. + > .multi-pane-slider { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + + .multi-pane-slider__container { + flex: 1; + min-height: 0; + height: auto; + } + } + + // The pane is the scroll container: bounded to the slider height and + // scrolls its content, so the details fill down to the pinned buttons. + // Hide the scrollbar to match the app's other scrollable content. + .multi-pane-slider__pane { + height: 100%; + min-height: 0; + overflow: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + // Single scroll container, so transaction details scroll fully instead of + // being cut off (drops the modal's fixed max-height). + .ChangeTrustInternal__Body { + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .ChangeTrustInternal__Body__Wrapper { + max-height: none; + min-height: 0; + } + + .ChangeTrustInternal__TransactionDetails { + overflow-y: visible; + } + + // Pin the nested submit/success footer to the bottom. + > .View__content { + flex: 1; + min-height: 0; + } + } + &__Loading { width: 100%; min-height: 30vh; diff --git a/extension/src/popup/helpers/useChangeTrustline.ts b/extension/src/popup/helpers/useChangeTrustline.ts deleted file mode 100644 index 3056a25d74..0000000000 --- a/extension/src/popup/helpers/useChangeTrustline.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { useDispatch, useSelector } from "react-redux"; - -import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; - -import { emitMetric } from "helpers/metrics"; -import { getCanonicalFromAsset } from "helpers/stellar"; - -import { AppDispatch } from "popup/App"; -import { getManageAssetXDR } from "popup/helpers/getManageAssetXDR"; -import { METRIC_NAMES } from "popup/constants/metricsNames"; -import { - publicKeySelector, - hardwareWalletTypeSelector, -} from "popup/ducks/accountServices"; -import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; -import { - resetSubmission, - signFreighterTransaction, - submitFreighterTransaction, - startHwSign, -} from "popup/ducks/transactionSubmission"; - -import { useNetworkFees } from "./useNetworkFees"; - -export const useChangeTrustline = ({ - assetCode, - assetIssuer, - setAssetSubmitting, - setIsSigningWithHardwareWallet, - setIsTrustlineErrorShowing, - setRowButtonShowing, -}: { - assetCode: string; - assetIssuer: string; - setAssetSubmitting?: (rowButtonShowing: string) => void; - setIsSigningWithHardwareWallet?: (value: boolean) => void; - setIsTrustlineErrorShowing?: (value: boolean) => void; - setRowButtonShowing?: (value: string) => void; -}): { - changeTrustline: ( - addTrustline: boolean, - successfulCallback?: () => Promise, - ) => Promise; -} => { - const dispatch: AppDispatch = useDispatch(); - const walletType = useSelector(hardwareWalletTypeSelector); - const networkDetails = useSelector(settingsNetworkDetailsSelector); - const publicKey = useSelector(publicKeySelector); - - const isHardwareWallet = !!walletType; - - const { fetchData: fetchFees } = useNetworkFees(); - - const canonicalAsset = getCanonicalFromAsset(assetCode, assetIssuer); - - const signAndSubmit = async ( - transactionXDR: string, - trackChangeTrustline: () => void, - successfulCallback?: () => Promise, - ) => { - const res = await dispatch( - signFreighterTransaction({ - transactionXDR, - network: networkDetails.networkPassphrase, - }), - ); - - if (signFreighterTransaction.fulfilled.match(res)) { - const submitResp = await dispatch( - submitFreighterTransaction({ - publicKey, - signedXDR: res.payload.signedTransaction, - networkDetails, - }), - ); - - if (submitFreighterTransaction.fulfilled.match(submitResp)) { - trackChangeTrustline(); - dispatch(resetSubmission()); - if (successfulCallback) { - await successfulCallback(); - } - } - - if (submitFreighterTransaction.rejected.match(submitResp)) { - setIsTrustlineErrorShowing?.(true); - } - - setAssetSubmitting?.(""); - setRowButtonShowing?.(""); - } - }; - - const changeTrustline = async ( - addTrustline: boolean, // false removes the trustline - successfulCallback?: () => Promise, - ) => { - setAssetSubmitting?.(canonicalAsset); - // Build the server here (on submit) rather than during render: the dApp - // popup mounts before network details hydrate, so constructing it at render - // time can hit an empty networkUrl (v16's Server throws on that). - const server = stellarSdkServer( - networkDetails.networkUrl, - networkDetails.networkPassphrase, - ); - const fees = await fetchFees(); - const transactionXDR: string = await getManageAssetXDR({ - publicKey, - assetCode, - assetIssuer, - addTrustline, - server, - recommendedFee: fees.recommendedFee, - networkDetails, - }); - - const trackChangeTrustline = () => { - emitMetric( - addTrustline - ? METRIC_NAMES.manageAssetAddAsset - : METRIC_NAMES.manageAssetRemoveAsset, - { code: assetCode, issuer: assetIssuer }, - ); - }; - - if (isHardwareWallet) { - await dispatch(startHwSign({ transactionXDR, shouldSubmit: true })); - setIsSigningWithHardwareWallet?.(true); - trackChangeTrustline(); - } else { - await signAndSubmit( - transactionXDR, - trackChangeTrustline, - successfulCallback, - ); - } - }; - - return { - changeTrustline, - }; -}; diff --git a/extension/src/popup/helpers/useSetupAddTokenFlow.ts b/extension/src/popup/helpers/useSetupAddTokenFlow.ts index f1055cd07d..34330f6793 100644 --- a/extension/src/popup/helpers/useSetupAddTokenFlow.ts +++ b/extension/src/popup/helpers/useSetupAddTokenFlow.ts @@ -1,6 +1,5 @@ import { useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { StrKey } from "stellar-sdk"; import { emitMetric } from "helpers/metrics"; @@ -12,13 +11,9 @@ import { hasPrivateKeySelector, } from "popup/ducks/accountServices"; -import { useChangeTrustline } from "./useChangeTrustline"; - type Params = { rejectToken: typeof rejectToken; addToken: typeof addToken; - assetCode: string; - assetIssuer: string; uuid: string; }; @@ -34,8 +29,6 @@ type Response = { export const useSetupAddTokenFlow = ({ rejectToken: rejectTokenFn, addToken: addTokenFn, - assetCode, - assetIssuer, uuid, }: Params): Response => { const [isConfirming, setIsConfirming] = useState(false); @@ -44,8 +37,6 @@ export const useSetupAddTokenFlow = ({ const dispatch: AppDispatch = useDispatch(); const hasPrivateKey = useSelector(hasPrivateKeySelector); - const { changeTrustline } = useChangeTrustline({ assetCode, assetIssuer }); - const rejectAndClose = () => { emitMetric(METRIC_NAMES.tokenRejectApi); dispatch(rejectTokenFn({ uuid })); @@ -53,22 +44,13 @@ export const useSetupAddTokenFlow = ({ }; const addTokenAndClose = async () => { - const addTokenDispatch = async () => { - await dispatch(addTokenFn({ uuid })); - }; - try { - if (StrKey.isValidEd25519PublicKey(assetIssuer)) { - await changeTrustline(true, addTokenDispatch); - } else { - await addTokenDispatch(); - } + await dispatch(addTokenFn({ uuid })); await emitMetric(METRIC_NAMES.tokenAddedApi); } catch (e) { console.error(e); await emitMetric(METRIC_NAMES.tokenFailedApi); } - window.close(); }; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 52339ec4bb..e6ee4aa0c0 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -77,6 +77,7 @@ "Authorizations": "Authorizations", "Authorize": "Authorize", "Authorized address": "Authorized address", + "Auto-lock timer": "Auto-lock timer", "available": "available", "Back": "Back", "Balance": "Balance", @@ -640,6 +641,7 @@ "To create a new account you need to send at least 1 XLM to it.": "To create a new account you need to send at least 1 XLM to it.", "To start using this account, fund it with at least 1 XLM.": "To start using this account, fund it with at least 1 XLM.", "Toggle Assets": "Toggle Assets", + "Token address": "Token address", "Token ID": "Token ID", "Token ID cannot contain spaces": "Token ID cannot contain spaces", "Token ID is required": "Token ID is required", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index db2c569c1c..af7209f1f9 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -77,6 +77,7 @@ "Authorizations": "Autorizações", "Authorize": "Autorizar", "Authorized address": "Endereço autorizado", + "Auto-lock timer": "Tempo para bloqueio automático", "available": "disponível", "Back": "Voltar", "Balance": "Saldo", @@ -640,6 +641,7 @@ "To create a new account you need to send at least 1 XLM to it.": "Para criar uma nova conta, você precisa enviar pelo menos 1 XLM para ela.", "To start using this account, fund it with at least 1 XLM.": "Para começar a usar esta conta, financie-a com pelo menos 1 XLM.", "Toggle Assets": "Alternar Ativos", + "Token address": "Endereço do token", "Token ID": "ID do Token", "Token ID cannot contain spaces": "ID do token não pode conter espaços", "Token ID is required": "ID do token é obrigatório", diff --git a/extension/src/popup/views/AddToken/__tests__/AddToken.test.tsx b/extension/src/popup/views/AddToken/__tests__/AddToken.test.tsx new file mode 100644 index 0000000000..1bf743404c --- /dev/null +++ b/extension/src/popup/views/AddToken/__tests__/AddToken.test.tsx @@ -0,0 +1,366 @@ +/** + * AddToken view – SAC vs SEP-41 routing test. + * + * Key challenge: the view's `useEffect` lists `handleTokenLookup` in its deps + * array, so returning a fresh function each render triggers an infinite loop. + * We solve this by returning a `useCallback`-stabilised reference from our + * mock of `useTokenLookup`, using `jest.requireActual("react")` inside the + * factory (the only way to call hooks inside a jest.mock factory). + * + * The `parsedSearchParam` URL helper expects base64-encoded JSON, so we spy + * on it and return a plain params object to avoid the atob() crash. + */ +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; +import { AddToken } from "popup/views/AddToken"; +import { Wrapper, TEST_PUBLIC_KEY } from "popup/__testHelpers__"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import * as UrlHelpers from "helpers/urls"; +import { addToken } from "popup/ducks/access"; +import { emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +const SAC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const SEP41_CONTRACT = + "CAAV3AE3VKD2P4TY7LWTQMMJHIJ4WOCZ5ANCIJPC3NRSERKVXNHBU2W7"; + +// --------------------------------------------------------------------------- +// Shared mutable state for useTokenLookup mock. +// Variable MUST start with "mock" (case-insensitive) for jest to allow it +// to be referenced inside jest.mock() factories (jest's hoisting scope rule). +// --------------------------------------------------------------------------- +// We use an object so the mock factory closes over the reference (not the +// value), allowing per-test updates to take effect. +const mockTokenLookupConfig = { + issuer: SAC_ISSUER, +}; + +// --------------------------------------------------------------------------- +// Module-level mocks +// --------------------------------------------------------------------------- + +jest.mock("helpers/hooks/useGetAppData", () => ({ + AppDataType: { RESOLVED: "resolved", REROUTE: "re-route" }, + useGetAppData: () => ({ + state: { + state: "SUCCESS", + data: { + type: "resolved", + account: { + publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + hasPrivateKey: true, + allAccounts: [ + { + hardwareWalletType: "", + imported: false, + name: "Account 1", + publicKey: + "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + }, + ], + bipPath: "m/44'/148'/0'", + tokenIdList: [], + }, + settings: { + networkDetails: { + isTestnet: true, + network: "TESTNET", + networkName: "Test SDF Network", + otherNetworkName: "Mainnet", + networkUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }, + networksList: [], + hiddenAssets: {}, + allowList: [], + error: "", + isDataSharingAllowed: false, + isMemoValidationEnabled: false, + isHideDustEnabled: false, + isOpenSidebarByDefault: false, + assetsLists: [], + autoLockTimeoutMinutes: 30, + isExperimentalModeEnabled: false, + isHashSigningEnabled: false, + isNonSSLEnabled: false, + }, + }, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(undefined), + }), +})); + +// Sentinel mock — lets us assert whether the review was shown +jest.mock( + "popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal", + () => ({ + ChangeTrustInternal: (props: any) => ( +
+ +
+ ), + }), +); + +// useTokenLookup mock that returns a *stable* handleTokenLookup via useCallback +// so the view's useEffect([contractId, handleTokenLookup]) doesn't loop. +jest.mock("popup/helpers/useTokenLookup", () => { + const { useCallback } = jest.requireActual("react"); + return { + useTokenLookup: ({ + setAssetRows, + setIsSearching, + setIsVerifiedToken, + setIsVerificationInfoShowing, + }: any) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleTokenLookup = useCallback(async (_contractId: string) => { + setAssetRows([ + { + code: "USDC", + // Read issuer from the shared config object (mutated per-test) + issuer: mockTokenLookupConfig.issuer, + contract: + "CAAV3AE3VKD2P4TY7LWTQMMJHIJ4WOCZ5ANCIJPC3NRSERKVXNHBU2W7", + domain: "centre.io", + }, + ]); + setIsVerifiedToken(true); + setIsVerificationInfoShowing(false); + setIsSearching(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return { handleTokenLookup }; + }, + }; +}); + +// AddToken imports isContractId + isAssetSac from soroban. Stub both: a contract +// id is a "C..." string, and a token is a SAC when the resolved issuer is a +// classic G-address — matching the SAC vs SEP-41 setup driven by +// mockTokenLookupConfig.issuer above. +jest.mock("popup/helpers/soroban", () => ({ + isContractId: (id?: string) => typeof id === "string" && id.startsWith("C"), + isAssetSac: ({ asset }: { asset: { issuer?: string } }) => + (asset?.issuer || "").startsWith("G"), +})); + +jest.mock("@shared/api/helpers/getIconUrlFromIssuer", () => ({ + getIconUrlFromIssuer: jest.fn().mockResolvedValue(""), +})); + +jest.mock("stellar-sdk", () => { + const original = jest.requireActual("stellar-sdk"); + return { + ...original, + StellarToml: { + Resolver: { + resolve: jest.fn().mockResolvedValue({ CURRENCIES: [] }), + }, + }, + }; +}); + +jest.mock("popup/helpers/blockaid", () => ({ + scanAsset: jest.fn().mockResolvedValue(null), + isAssetSuspicious: jest.fn().mockReturnValue(false), + isAssetMalicious: jest.fn().mockReturnValue(false), + shouldTreatAssetAsUnableToScan: jest.fn().mockReturnValue(false), + useIsAssetSuspicious: jest.fn().mockReturnValue(() => false), + useBlockaidOverrideState: jest.fn().mockReturnValue(null), +})); + +jest.mock("@shared/api/internal", () => ({ + getBlockaidOverrideState: jest.fn().mockResolvedValue(null), +})); + +jest.mock("popup/helpers/useIsDomainListedAllowed", () => ({ + useIsDomainListedAllowed: jest + .fn() + .mockReturnValue({ isDomainListedAllowed: true }), +})); + +jest.mock("popup/helpers/useMarkQueueActive", () => ({ + useMarkQueueActive: jest.fn(), +})); + +jest.mock("popup/helpers/useNetworkFees", () => ({ + useNetworkFees: () => ({ + recommendedFee: "0.0011234", + networkCongestion: "Low", + fetchData: jest.fn().mockResolvedValue({ recommendedFee: "0.0011234" }), + }), +})); + +jest.mock("popup/helpers/route", () => ({ + reRouteOnboarding: jest.fn(), +})); + +jest.mock("helpers/metrics", () => ({ + emitMetric: jest.fn().mockResolvedValue(undefined), + storeAccountMetricsData: jest.fn(), + registerHandler: jest.fn(), +})); + +jest.mock("popup/ducks/access", () => ({ + rejectToken: jest.fn(() => ({ type: "access/rejectToken" })), + addToken: jest.fn(() => ({ type: "access/addToken" })), +})); + +jest.mock("popup/helpers/useSetupAddTokenFlow", () => ({ + useSetupAddTokenFlow: () => ({ + isConfirming: false, + isPasswordRequired: false, + setIsPasswordRequired: jest.fn(), + verifyPasswordThenAddToken: jest.fn(), + handleApprove: jest.fn(), + rejectAndClose: jest.fn(), + }), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const mockState = { + auth: { + hasPrivateKey: true, + publicKey: TEST_PUBLIC_KEY, + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + allAccounts: [ + { + hardwareWalletType: "", + imported: false, + name: "Account 1", + publicKey: TEST_PUBLIC_KEY, + }, + ], + }, + settings: { + networkDetails: TESTNET_NETWORK_DETAILS, + }, +}; + +const renderAt = () => + render( + + + , + ); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AddToken SAC / SEP-41 routing", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Bypass base64 URL parsing — inject params directly so atob() doesn't crash + jest.spyOn(UrlHelpers, "parsedSearchParam").mockReturnValue({ + contractId: SEP41_CONTRACT, + domain: "example.com", + url: "https://example.com", + uuid: "test-uuid", + networkPassphrase: "", + } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("SAC: Confirm opens the Change Trust review instead of submitting", async () => { + // G... issuer → StrKey.isValidEd25519PublicKey = true → SAC branch + mockTokenLookupConfig.issuer = SAC_ISSUER; + renderAt(); + + const confirm = await screen.findByTestId("add-token-approve"); + fireEvent.click(confirm); + + await waitFor(() => + expect( + screen.getByTestId("ChangeTrustInternal-mock"), + ).toBeInTheDocument(), + ); + }); + + it("SAC: Add Token screen shows Fee and Token address rows", async () => { + mockTokenLookupConfig.issuer = SAC_ISSUER; + renderAt(); + + await screen.findByTestId("add-token-approve"); + + expect( + screen.getByTestId("AddToken__Metadata__Row__Fee"), + ).toHaveTextContent("0.0011234 XLM"); + expect( + screen.getByTestId("AddToken__Metadata__Row__TokenAddress"), + ).toBeInTheDocument(); + }); + + it("SEP-41: Add Token screen does not show Fee or Token address rows", async () => { + mockTokenLookupConfig.issuer = SEP41_CONTRACT; + renderAt(); + + await screen.findByTestId("add-token-approve"); + + expect( + screen.queryByTestId("AddToken__Metadata__Row__Fee"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("AddToken__Metadata__Row__TokenAddress"), + ).not.toBeInTheDocument(); + }); + + it("SAC: a successful review resolves the dApp request (addToken + metric + close)", async () => { + mockTokenLookupConfig.issuer = SAC_ISSUER; + const closeSpy = jest + .spyOn(window, "close") + .mockImplementation(() => undefined); + + renderAt(); + + const confirm = await screen.findByTestId("add-token-approve"); + fireEvent.click(confirm); + + const successBtn = await screen.findByText("mock-success"); + await act(async () => { + fireEvent.click(successBtn); + }); + + await waitFor(() => + expect(jest.mocked(addToken)).toHaveBeenCalledWith({ uuid: "test-uuid" }), + ); + expect(jest.mocked(emitMetric)).toHaveBeenCalledWith( + METRIC_NAMES.tokenAddedApi, + ); + expect(closeSpy).toHaveBeenCalled(); + }); + + it("SEP-41: Confirm does not open the Change Trust review", async () => { + // C... issuer → StrKey.isValidEd25519PublicKey = false → SEP-41 branch + mockTokenLookupConfig.issuer = SEP41_CONTRACT; + renderAt(); + + const confirm = await screen.findByTestId("add-token-approve"); + fireEvent.click(confirm); + + // Allow any pending state updates to settle + await act(async () => {}); + + expect( + screen.queryByTestId("ChangeTrustInternal-mock"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index 0ad6e50cfb..5887c103f3 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -10,8 +10,15 @@ import { import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router-dom"; -import { useSelector } from "react-redux"; -import { StellarToml } from "stellar-sdk"; +import { useSelector, useDispatch } from "react-redux"; +import { BASE_FEE, StellarToml } from "stellar-sdk"; + +import { AppDispatch } from "popup/App"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { emitMetric } from "helpers/metrics"; +import { stroopToXlm, truncateString } from "helpers/stellar"; +import { useNetworkFees } from "popup/helpers/useNetworkFees"; +import { ChangeTrustInternal } from "popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal"; import { BlockAidScanAssetResult } from "@shared/api/types"; import { getIconUrlFromIssuer } from "@shared/api/helpers/getIconUrlFromIssuer"; @@ -35,7 +42,7 @@ import { VerifyAccount } from "popup/views/VerifyAccount"; import { View } from "popup/basics/layout/View"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; import { useTokenLookup } from "popup/helpers/useTokenLookup"; -import { isContractId } from "popup/helpers/soroban"; +import { isContractId, isAssetSac } from "popup/helpers/soroban"; import { scanAsset, isAssetSuspicious, @@ -101,6 +108,24 @@ export const AddToken = () => { const isLoading = isSearching || assetIcon === undefined || assetTomlName === undefined; + const dispatch: AppDispatch = useDispatch(); + const [showTrustlineReview, setShowTrustlineReview] = useState(false); + + // `recommendedFee` is the network-recommended fee in XLM after useNetworkFees + // resolves; pre-fetch it is the raw BASE_FEE (stroops), so guard against that + // and fall back to the base fee converted to XLM. This same value is both + // displayed here and passed into the trustline review so the disclosed fee + // matches the charged fee. + const { recommendedFee } = useNetworkFees(); + const baseFeeXlm = stroopToXlm(BASE_FEE).toString(); + const displayFee = recommendedFee === BASE_FEE ? baseFeeXlm : recommendedFee; + + const handleSacSuccess = async () => { + await dispatch(addToken({ uuid })); + await emitMetric(METRIC_NAMES.tokenAddedApi); + window.close(); + }; + const { isConfirming, isPasswordRequired, @@ -111,8 +136,6 @@ export const AddToken = () => { } = useSetupAddTokenFlow({ rejectToken, addToken, - assetCode, - assetIssuer, uuid, }); @@ -288,6 +311,16 @@ export const AddToken = () => { const { networkPassphrase, networkName } = state.data.settings.networkDetails; + // Verify the contract is the derived SAC, not just a G-address issuer. + const isSac = isAssetSac({ + asset: { + code: assetCode, + issuer: assetIssuer, + contract: assetCurrency?.contract, + }, + networkDetails: state.data.settings.networkDetails, + }); + if (entryNetworkPassphrase && entryNetworkPassphrase !== networkPassphrase) { return ( { ); } + if (showTrustlineReview && isSac && assetCurrency) { + return ( + setShowTrustlineReview(false)} + onSuccess={handleSacSuccess} + onClose={rejectAndClose} + initialFee={displayFee} + isFullHeight + /> + ); + } + return ( @@ -441,6 +496,34 @@ export const AddToken = () => {
+ {isSac && ( + <> +
+
+ + {t("Fee")} +
+
+ {`${displayFee} XLM`} +
+
+
+
+ + {t("Token address")} +
+
+ {truncateString(contractId)} +
+
+ + )}
, blockaidData ? ( @@ -475,9 +558,13 @@ export const AddToken = () => { size="lg" variant={isMaliciousAsset ? "error" : "secondary"} isLoading={isConfirming} - onClick={() => handleApprove()} + onClick={() => + isSac ? setShowTrustlineReview(true) : handleApprove() + } > - {t("Confirm")} + {/* SAC is a 2-step flow (Add Token → Change Trust review), so this + first button advances ("Continue"); SEP-41 confirms directly. */} + {isSac ? t("Continue") : t("Confirm")} diff --git a/extension/src/popup/views/AddToken/styles.scss b/extension/src/popup/views/AddToken/styles.scss index 9348568806..0497ea9461 100644 --- a/extension/src/popup/views/AddToken/styles.scss +++ b/extension/src/popup/views/AddToken/styles.scss @@ -16,7 +16,6 @@ } &__wrapper { - overflow: scroll; position: relative; flex-direction: column; display: flex; diff --git a/extension/src/popup/views/AutoLockTimer/index.tsx b/extension/src/popup/views/AutoLockTimer/index.tsx index 2d2af8a0b4..1c12a57542 100644 --- a/extension/src/popup/views/AutoLockTimer/index.tsx +++ b/extension/src/popup/views/AutoLockTimer/index.tsx @@ -92,10 +92,8 @@ export const AutoLockTimer = () => { setIsSaving(true); await dispatch( saveSettings({ - isDataSharingAllowed: - settings.isDataSharingAllowed ?? false, - isMemoValidationEnabled: - settings.isMemoValidationEnabled ?? true, + isDataSharingAllowed: settings.isDataSharingAllowed ?? false, + isMemoValidationEnabled: settings.isMemoValidationEnabled ?? true, isHideDustEnabled: settings.isHideDustEnabled ?? true, isOpenSidebarByDefault: settings.isOpenSidebarByDefault ?? false, autoLockTimeoutMinutes: minutes, @@ -106,7 +104,7 @@ export const AutoLockTimer = () => { return ( - +
{VALID_AUTO_LOCK_TIMEOUT_MINUTES.map((minutes) => {