diff --git a/README.md b/README.md index dd58982..77b3b71 100644 --- a/README.md +++ b/README.md @@ -1898,7 +1898,8 @@ view would leave a wallet one click from deletion. - Contract address input - Status line ("Looking up token...", cleared or replaced on failure) - Common token quick-pick buttons (top 25 by market cap), which fill the - contract address input + contract address input. Shown on Ethereum Mainnet only, because the + bundled token list holds mainnet contracts - "Add" button - **Transitions**: - "Add" (valid contract) → tracks the token, pops the stack, and re-renders @@ -1915,16 +1916,19 @@ view would leave a wallet one click from deletion. - Wallets: one row per wallet with its name (tap to rename inline), a `[recovery phrase]` button on HD wallets only, and an `[x]` delete button, plus a "+ Add wallet" button - - Tracked Tokens: one row per tracked token with an `[x]` remove button, - plus a "+ Add token" button + - Tracked Tokens: one row per token tracked on the active network with an + `[x]` remove button, plus a "+ Add token" button - Display: "Show tracked tokens with zero balance" checkbox, "UTC Timestamps" checkbox, and a Theme selector (System / Light / Dark) - Network: network selector (Ethereum Mainnet / Sepolia Testnet). The network changes only here, or when the user approves a site's request on **NetworkApproval**; either way, switching restores the RPC and Blockscout endpoints last used on that network, or that network's defaults if it has - none, and sends `chainChanged` with the new chain id to every open tab. - Choosing the network already active changes nothing and sends nothing + none, and the tokens tracked on that network, and sends `chainChanged` + with the new chain id to every open tab. A token is tracked only on the + network it was added on, because a contract address names a token on one + network only. Choosing the network already active changes nothing and + sends nothing - Ethereum RPC: endpoint URL input + "Save" button (validated against `eth_chainId` before being saved) - Blockscout API: endpoint URL input + "Save" button (validated against @@ -2148,7 +2152,8 @@ view would leave a wallet one click from deletion. #### SettingsAddToken (`settings-addtoken`) - **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked - across every address, unlike AddToken which is reached from one address. + across every address on the active network, unlike AddToken which is reached + from one address. - **Elements**: - "Back" button, "Add Token" heading - Instruction text @@ -2157,8 +2162,12 @@ view would leave a wallet one click from deletion. - "Or pick from top 100:" dropdown (already-tracked tokens are disabled) + "Add selected" button - "Or enter contract address:" input, a status line, and an "Add" button + - The two lists from the bundled token list, "Top tokens:" and "Or pick from + top 100:", are shown on Ethereum Mainnet only, because that list holds + mainnet contracts. On any other network the contract address is the only + add path - **Transitions**: - - Any of the three add paths, on success → adds the token and shows an + - Any of the add paths shown, on success → adds the token and shows an "Added SYMBOL" flash message (no screen change) - A duplicate, a scam-listed address, or a failed contract lookup → flash message, no screen change @@ -2376,8 +2385,9 @@ view would leave a wallet one click from deletion. - **Transitions**: - "Switch" → closes popup; the background switches the network as **Settings** does, restoring the RPC and Blockscout endpoints last used on - that network (or that network's defaults if it has none), sends - `chainChanged` to every open tab, and answers the site with success + that network (or that network's defaults if it has none) and the tokens + tracked on that network, sends `chainChanged` to every open tab, and + answers the site with success - "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and nothing changes - Popup window closed without answering → the same as "Reject" diff --git a/src/popup/index.html b/src/popup/index.html index a433783..41af268 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -843,7 +843,7 @@ id="add-token-info" class="text-xs text-muted mb-2 min-h-[1.25rem] invisible" > -
+
@@ -1326,36 +1326,39 @@

Add Token

Pick a common token or enter a contract address manually. + Common tokens are offered on Ethereum Mainnet only.

- -
- -
-
+
+ +
+ +
+
- -
- - - + +
+ + + +
diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js index 45829e3..514be3a 100644 --- a/src/popup/views/addToken.js +++ b/src/popup/views/addToken.js @@ -9,6 +9,12 @@ function show() { $("add-token-address").value = ""; $("add-token-info").textContent = ""; $("add-token-info").style.visibility = "hidden"; + // The bundled list holds Ethereum Mainnet contracts, which name no token + // on any other network. + $("add-token-common").classList.toggle( + "hidden", + state.networkId !== "mainnet", + ); const list = $("common-token-list"); list.innerHTML = getTopTokens(25) .map( diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js index fd6186c..edba81b 100644 --- a/src/popup/views/settings.js +++ b/src/popup/views/settings.js @@ -325,6 +325,7 @@ function init(ctx) { notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); $("settings-rpc").value = state.rpcUrl; $("settings-blockscout").value = state.blockscoutUrl; + renderTrackedTokens(); showFlash("Switched to " + net.name + "."); }); diff --git a/src/popup/views/settingsAddToken.js b/src/popup/views/settingsAddToken.js index 1ad1508..62b0f5d 100644 --- a/src/popup/views/settingsAddToken.js +++ b/src/popup/views/settingsAddToken.js @@ -75,6 +75,12 @@ function show() { $("settings-addtoken-address").value = ""; $("settings-addtoken-info").textContent = ""; $("settings-addtoken-info").style.visibility = "hidden"; + // The bundled list holds Ethereum Mainnet contracts, which name no token + // on any other network. + $("settings-addtoken-common").classList.toggle( + "hidden", + state.networkId !== "mainnet", + ); renderTop10(); renderDropdown(); showView("settings-addtoken"); diff --git a/src/shared/chainSwitchFields.js b/src/shared/chainSwitchFields.js index e4d46d3..24ed8e7 100644 --- a/src/shared/chainSwitchFields.js +++ b/src/shared/chainSwitchFields.js @@ -38,6 +38,16 @@ function applyChainSwitchFields(s, newNetworkId) { blockscoutUrl: s.blockscoutUrl, }; const remembered = s.networkEndpoints[net.id] || {}; + + // Tracked tokens are kept per network the same way, because a contract + // address names a token on one network only. s.trackedTokens stays the + // active network's list, so nothing that reads it changes: the outgoing + // list is put away under its network, and the incoming network's list, or + // an empty one, takes its place. + if (!s.networkTrackedTokens) s.networkTrackedTokens = {}; + s.networkTrackedTokens[s.networkId] = s.trackedTokens; + s.trackedTokens = s.networkTrackedTokens[net.id] || []; + s.networkId = net.id; s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl; s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl; diff --git a/src/shared/persistedState.js b/src/shared/persistedState.js index e2ca2a7..4613045 100644 --- a/src/shared/persistedState.js +++ b/src/shared/persistedState.js @@ -23,6 +23,11 @@ const DEFAULT_STATE = { hasWallet: false, wallets: [], trackedTokens: [], + // Tracked tokens remembered per network: { [networkId]: [token, ...] }. + // trackedTokens above is the active network's list; this is what the + // others are restored from when the active network changes. See + // applyChainSwitchFields(). + networkTrackedTokens: {}, networkId: "mainnet", rpcUrl: DEFAULT_RPC_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, @@ -289,6 +294,20 @@ function normalizePersisted(saved) { // profile whose wallets are perfectly fine. An empty list is a legitimate // value and survives. out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens)); + // Each remembered list is floored like trackedTokens, because + // applyChainSwitchFields() puts it straight back onto trackedTokens when + // its network becomes the active one. A key that is not a network in + // networks.js is dropped, which also keeps an own "__proto__" key from + // reaching the plain assignment below. + out.networkTrackedTokens = {}; + if (isRecord(saved.networkTrackedTokens)) { + for (const netId of Object.keys(saved.networkTrackedTokens)) { + if (!isKnownNetworkId(netId)) continue; + out.networkTrackedTokens[netId] = structuredClone( + tokenRefs(saved.networkTrackedTokens[netId]), + ); + } + } // The loud refusal for an unknown id is assertStateUsable(); this is the // floor under it. networkId is an object KEY into networkEndpoints below, // so a value that is not a network in networks.js must never get that far diff --git a/src/shared/state.js b/src/shared/state.js index c580819..ecc8fc2 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -42,9 +42,11 @@ const { log } = require("./log"); // and writes THIS object, never the proxy: the guard is for callers. const rawState = { ...DEFAULT_STATE, - // Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields() - // mutates this map in place, and a spread copies the reference. + // Their own objects, not the ones DEFAULT_STATE holds: + // applyChainSwitchFields() mutates these maps in place, and a spread copies + // the reference. networkEndpoints: {}, + networkTrackedTokens: {}, currentView: null, selectedWallet: null, selectedAddress: null, @@ -438,12 +440,14 @@ function mergeNetworkEndpoints(base, ours, theirs) { // // Every other persisted field stays a whole-field diff: // `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no -// per-element identity to merge by; `tokenHolderCache` is a map shaped like -// the ones above, but nothing in src/ ever writes an entry into it — it is -// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is -// no in-place mutation for a whole-field diff to collide with; `viewData` is -// this page's own UI scratch space, not data another page has any reason to -// share membership of. +// per-element identity to merge by; `networkTrackedTokens` is diffed whole like +// `trackedTokens` and `networkId`, the two fields a network switch moves +// together with it; `tokenHolderCache` is a map shaped like the ones above, +// but nothing in src/ ever writes an entry into it — it is only ever reset +// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place +// mutation for a whole-field diff to collide with; `viewData` is this page's +// own UI scratch space, not data another page has any reason to share +// membership of. // // This does not make two pages that both change the SAME leaf concurrently // safe: last write wins on that one leaf, same as before. What it removes diff --git a/tests/coldWorkerChainSwitch.test.js b/tests/coldWorkerChainSwitch.test.js index 34a09e7..06425ab 100644 --- a/tests/coldWorkerChainSwitch.test.js +++ b/tests/coldWorkerChainSwitch.test.js @@ -204,17 +204,21 @@ describe("a chain switch on a worker that never loaded state", () => { expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); // And it took nothing else with it. Without the load these come back - // as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the + // as [], {}, {} and "system" from DEFAULT_STATE — every wallet in the // extension gone, encrypted secrets included. expect(after.wallets).toEqual(walletFixture()); expect(after.hasWallet).toBe(true); expect(after.activeAddress).toBe(ADDRESS); expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] }); - expect(after.trackedTokens).toEqual([ - { address: TOKEN, symbol: "DAI", decimals: 18 }, - ]); + expect(after.networkTrackedTokens).toEqual({ + mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }], + }); expect(after.theme).toBe("dark"); + // The mainnet token is tracked on mainnet only: Sepolia starts with + // none of its own. + expect(after.trackedTokens).toEqual([]); + // The user's mainnet endpoint is remembered rather than replaced by // the public default, so switching back returns it. expect(after.networkEndpoints.mainnet).toEqual({ @@ -226,6 +230,9 @@ describe("a chain switch on a worker that never loaded state", () => { expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC); expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); expect(bg.persisted().wallets).toEqual(walletFixture()); + expect(bg.persisted().trackedTokens).toEqual([ + { address: TOKEN, symbol: "DAI", decimals: 18 }, + ]); }); test("compares the requested chain against the stored one, not the default", async () => { diff --git a/tests/persistedFieldContract.test.js b/tests/persistedFieldContract.test.js index 637d2ab..104a3a5 100644 --- a/tests/persistedFieldContract.test.js +++ b/tests/persistedFieldContract.test.js @@ -97,6 +97,7 @@ const { normalizePersisted, } = require("../src/shared/persistedState"); const { stateProblem } = require("../src/shared/stateSchema"); +const { isKnownNetworkId } = require("../src/shared/networks"); const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const { bootPopup, @@ -193,6 +194,29 @@ const CONTRACT = [ floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]], holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)), }, + { + field: "networkTrackedTokens", + kind: KIND.ENTRIES, + hostile: [42, "notarecord", { sepolia: "notalist" }], + floorOnly: [ + [1, 2], + { sepolia: [1, 2] }, + { mainnet: [null, {}, [TOKEN_ADDRESS]] }, + { notanetwork: [{ address: TOKEN_ADDRESS }] }, + JSON.parse('{"__proto__":[{"address":"0xabc"}]}'), + ], + // Each list is put straight back onto trackedTokens by + // applyChainSwitchFields() when its network becomes the active one, so + // it is floored the same way; a key that names no network is dropped. + holds: (v) => + isRecord(v) && + Object.getPrototypeOf(v) === Object.prototype && + Object.keys(v).every( + (id) => + isKnownNetworkId(id) && + everyEntry(v[id], (t) => isRecord(t) && isText(t.address)), + ), + }, { field: "allowedSites", kind: KIND.ENTRIES, diff --git a/tests/trackedTokensPerNetwork.test.js b/tests/trackedTokensPerNetwork.test.js new file mode 100644 index 0000000..2525573 --- /dev/null +++ b/tests/trackedTokensPerNetwork.test.js @@ -0,0 +1,102 @@ +// Tracked tokens are kept per network, and the two add-token screens offer the +// bundled token list, which holds Ethereum Mainnet contracts, on Ethereum +// Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523). Driven +// through the real popup over a stored profile; the same switch made by the +// background is in tests/coldWorkerChainSwitch.test.js. + +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, + TOKEN_ADDRESS, +} = require("./support/popupBoot"); + +const SEPOLIA_TOKEN = { + address: TOKEN_ADDRESS, + symbol: "TST", + decimals: 18, + name: "Test Token", +}; +const MAINNET_TOKEN = { + address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + symbol: "DAI", + decimals: 18, + name: "Dai Stablecoin", +}; + +afterEach(() => { + cleanupPopup(); +}); + +// The popup reopened onto SettingsAddToken, on `networkId`. +function bootOnAddToken(networkId, extra) { + return bootPopup( + unversionedValidProfile({ + networkId, + currentView: "settings-addtoken", + ...extra, + }), + ); +} + +// Picks `networkId` in the Settings network selector, as the user does. +async function chooseNetwork(boot, networkId) { + const select = boot.node("settings-network"); + select.value = networkId; + for (const fn of select.listeners.change) await fn(); + await boot.settle(); +} + +describe.each([ + ["mainnet", false], + ["sepolia", true], +])("on %s", (networkId, hidden) => { + test(`the common tokens are hidden: ${hidden}`, async () => { + const boot = await bootOnAddToken(networkId); + expect(boot.pageErrors).toEqual([]); + expect(boot.visibleViews()).toEqual(["settings-addtoken"]); + expect(boot.hidden("settings-addtoken-common")).toBe(hidden); + + require("../src/popup/views/addToken").show(); + expect(boot.visibleViews()).toEqual(["add-token"]); + expect(boot.hidden("add-token-common")).toBe(hidden); + }); +}); + +test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => { + const boot = await bootOnAddToken("sepolia", { + networkTrackedTokens: { mainnet: [MAINNET_TOKEN] }, + }); + const stored = () => boot.storage.read("autistmask"); + const { lookupTokenInfo } = require("../src/shared/balances"); + lookupTokenInfo.mockResolvedValueOnce({ + symbol: SEPOLIA_TOKEN.symbol, + decimals: SEPOLIA_TOKEN.decimals, + name: SEPOLIA_TOKEN.name, + }); + + boot.node("settings-addtoken-address").value = TOKEN_ADDRESS; + await boot.click("btn-settings-addtoken-manual"); + + expect(lookupTokenInfo).toHaveBeenCalledWith( + TOKEN_ADDRESS, + expect.any(String), + "sepolia", + expect.anything(), + ); + expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); + + await chooseNetwork(boot, "mainnet"); + expect(stored().networkId).toBe("mainnet"); + expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]); + const onMainnet = boot.node("settings-tracked-tokens").innerHTML; + expect(onMainnet).toContain("DAI"); + expect(onMainnet).not.toContain("TST"); + + await chooseNetwork(boot, "sepolia"); + expect(stored().networkId).toBe("sepolia"); + expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); + const onSepolia = boot.node("settings-tracked-tokens").innerHTML; + expect(onSepolia).toContain("TST"); + expect(onSepolia).not.toContain("DAI"); +});