From f9318bafec5fb28cf9453b72eeb391aef8bf9321 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 18 Oct 2026 23:22:32 +0000 Subject: [PATCH] fix: tracked tokens are kept per network, and bundled picks are offered on mainnet only (closes #523) A network switch now puts the outgoing network's tracked tokens away in a new persisted field, networkTrackedTokens, and brings back the incoming network's list, or an empty one, the way endpoints are already kept per network. trackedTokens stays the active network's list, so its readers are unchanged, and the background's switch keeps the lists too because it goes through the same function. The two add-token screens show their common-token picks, which come from the bundled list of mainnet contracts, on Ethereum Mainnet only; adding by contract address works on every network. Settings redraws its tracked-token list after a switch. Model: opus-5-5 --- README.md | 28 ++++--- src/popup/index.html | 59 ++++++++------- src/popup/views/addToken.js | 6 ++ src/popup/views/settings.js | 1 + src/popup/views/settingsAddToken.js | 6 ++ src/shared/chainSwitchFields.js | 10 +++ src/shared/persistedState.js | 19 +++++ src/shared/state.js | 20 +++-- tests/coldWorkerChainSwitch.test.js | 15 +++- tests/persistedFieldContract.test.js | 24 ++++++ tests/trackedTokensPerNetwork.test.js | 102 ++++++++++++++++++++++++++ 11 files changed, 241 insertions(+), 49 deletions(-) create mode 100644 tests/trackedTokensPerNetwork.test.js 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"); +});