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");
+});