From 9c67e87ae50e5727b66649e9b65ea1f3af94cac0 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Mon, 19 Oct 2026 11:50:39 +0200
Subject: [PATCH] fix: tracked tokens are kept per network, and bundled picks
are offered on mainnet only (closes #523)
A network switch 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, as endpoints are kept; the background's switch uses the
same function. The two add-token screens offer their common-token picks on
Ethereum Mainnet only. After a switch in Settings, Back renders the screen it
lands on again, and the stack is cut at the first screen that cannot be
rendered from saved state, or at AddressToken for any token but the native
one; the cut stack is saved with the switch. ConfirmTx drawn again keeps the
network it was reviewed on. A contract lookup that answers after a switch adds
nothing.
Model: opus-5-5
---
README.md | 81 ++++--
src/popup/index.html | 59 ++--
src/popup/viewRouter.js | 13 +-
src/popup/views/addToken.js | 20 +-
src/popup/views/confirmTx.js | 6 +-
src/popup/views/settings.js | 23 ++
src/popup/views/settingsAddToken.js | 20 +-
src/shared/chainSwitchFields.js | 10 +
src/shared/persistedState.js | 19 ++
src/shared/state.js | 20 +-
tests/coldWorkerChainSwitch.test.js | 15 +-
tests/flashLine.test.js | 1 +
tests/persistedFieldContract.test.js | 24 ++
tests/trackedTokensPerNetwork.test.js | 372 ++++++++++++++++++++++++++
14 files changed, 610 insertions(+), 73 deletions(-)
create mode 100644 tests/trackedTokensPerNetwork.test.js
diff --git a/README.md b/README.md
index c765c60..4c8360e 100644
--- a/README.md
+++ b/README.md
@@ -1498,6 +1498,19 @@ such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
+A network switch in Settings leaves every screen beneath Settings showing the
+previous network, so after it "Back" behaves as in a reopened popup: every
+screen on the stack counts as not yet rendered, and the stack is cut at the
+first screen that cannot be rendered from saved state, such as Send or AddToken,
+or at AddressToken for any token but the native one, because that token was
+picked on the previous network. The cut stack is saved with the switch, so a
+popup closed straight after it and reopened onto Settings lands in the same
+place. "Back" then renders the screen it lands on for the new network, except
+ConfirmTx, which keeps the network its transaction was reviewed on, and anything
+typed there and not saved, which belonged to the previous network, is dropped.
+Without a wallet the stack is not cut, because it starts at Welcome, which shows
+nothing from a network.
+
Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
exit from that screen rather than only on its "Back" button, so nothing secret
@@ -1795,19 +1808,22 @@ view would leave a wallet one click from deletion.
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings since this
- transaction was reviewed, with this screen open or before the popup
- reopened onto it → nothing is signed; "The active network changed after
- this transaction was reviewed, so it was not sent. Please go back and
- review it again." in the errors box, no screen change. The transaction
- carries the chain id of the network active when Send showed this screen,
- and keeps it when the screen is drawn again from the saved transaction, so
- it cannot be signed for another. The screen describes it on that network,
- whichever network is active: its amounts are in that network's native
- currency, its USD values are that network's, so a testnet transaction
- shows none, its From, To and token contract links open that network's
- explorer, the balance read, the fee estimate and the contract and
- never-sent recipient checks ask that network's RPC endpoint, and the
- Etherscan label check asks that network's explorer
+ transaction was reviewed → nothing is signed; "The active network changed
+ after this transaction was reviewed, so it was not sent. Please go back
+ and review it again." in the errors box, no screen change. After a switch,
+ "Back" from Settings draws this screen again from the saved transaction,
+ as a popup reopened onto it does; when Send is beneath it on the stack, as
+ when Send showed it in the same popup, "Back" lands below Send instead
+ (see the top of Screen Map). The transaction carries the chain id of the
+ network active when Send showed this screen, and keeps it when the screen
+ is drawn again from the saved transaction, so it cannot be signed for
+ another. The screen describes it on that network, whichever network is
+ active: its amounts are in that network's native currency, its USD values
+ are that network's, so a testnet transaction shows none, its From, To and
+ token contract links open that network's explorer, the balance read, the
+ fee estimate and the contract and never-sent recipient checks ask that
+ network's RPC endpoint, and the Etherscan label check asks that network's
+ explorer
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
@@ -1945,13 +1961,18 @@ 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
**AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
+ - "Add", then a network switch in Settings before the contract lookup
+ answers → nothing is tracked; "The active network changed before the
+ lookup finished, so the token was not added." flash message, no screen
+ change
- "Back" → previous screen (AddressDetail)
#### Settings (`settings`)
@@ -1962,16 +1983,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
@@ -2016,7 +2040,8 @@ view would leave a wallet one click from deletion.
- `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change)
- - "Back" (or Settings gear again) → previous screen (Home)
+ - "Back" (or Settings gear again) → previous screen (Home); after a network
+ switch, the stack is cut first (see the top of Screen Map)
#### ShowRecoveryPhrase (`show-phrase`)
@@ -2199,7 +2224,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
@@ -2208,11 +2234,19 @@ 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
+ - "Add" by contract address, then a network switch in Settings before the
+ lookup answers → nothing is tracked; "The active network changed before
+ the lookup finished, so the token was not added." flash message, no screen
+ change
- "Back" → previous screen (Settings)
#### SiteApproval (`approve-site`)
@@ -2429,8 +2463,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 9d8ba02..b1a42b9 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -852,7 +852,7 @@
id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
>
-
+
@@ -1336,36 +1336,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/viewRouter.js b/src/popup/viewRouter.js
index 43ee2e0..5f1d7bf 100644
--- a/src/popup/viewRouter.js
+++ b/src/popup/viewRouter.js
@@ -38,9 +38,11 @@ function markViewRendered(view) {
if (view) renderedViews.add(view);
}
-// Begin a fresh page-load scope. The popup gets one by being loaded; the
+// Begin a fresh page-load scope. The popup gets one by being loaded, and
+// asks for one when the user switches the network, because every screen
+// then on the page shows the previous network (views/settings.js); the
// unit tests, which simulate several page loads against one module
-// instance, ask for one.
+// instance, ask for one too.
function resetRenderedViews() {
renderedViews.clear();
}
@@ -217,9 +219,10 @@ function renderView(view, state, views) {
// in the two cases where the view is known to be on the page already:
//
// - It is not one the popup renders from persisted state. The restored
-// stack is filtered against RESTORABLE_VIEWS, so such a view can only
-// be on the stack from this page load, where forward navigation
-// rendered it on the way in.
+// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
+// it the same way (views/settings.js), so such a view can only be on the
+// stack from this page load, where forward navigation rendered it on the
+// way in.
// - This page load has rendered it. Re-rendering would re-fetch and
// clobber what it holds; Home is rendered anyway, see above.
//
diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js
index 45829e3..76c96d0 100644
--- a/src/popup/views/addToken.js
+++ b/src/popup/views/addToken.js
@@ -1,6 +1,6 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
-const { state, saveState } = require("../../shared/state");
+const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
@@ -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(
@@ -46,6 +52,9 @@ function init(ctx) {
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr);
+ // The network the lookup asks, which Settings may switch away from
+ // before it answers, as for a transaction in confirmTx.js.
+ const chainId = currentNetwork().chainId;
try {
const info = await lookupTokenInfo(
contractAddr,
@@ -53,6 +62,15 @@ function init(ctx) {
state.networkId,
ctx.pageClosed,
);
+ // A contract address names a token on one network only, so an
+ // answer after a switch is not added to the new network's list.
+ if (currentNetwork().chainId !== chainId) {
+ showFlash(
+ "The active network changed before the lookup finished," +
+ " so the token was not added.",
+ );
+ return;
+ }
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,
diff --git a/src/popup/views/confirmTx.js b/src/popup/views/confirmTx.js
index d3196f4..a7b50fe 100644
--- a/src/popup/views/confirmTx.js
+++ b/src/popup/views/confirmTx.js
@@ -647,9 +647,9 @@ function init(_ctx) {
}
// The screen describes the network the transaction was reviewed for,
- // which Settings may have switched away from since: with this screen
- // open, where Back only unhides it, or before the popup reopened onto
- // it.
+ // which Settings may have switched away from since: "Back" from
+ // Settings after a switch draws this screen again from the saved
+ // transaction, as a popup reopened onto it does.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js
index fd6186c..cefde1c 100644
--- a/src/popup/views/settings.js
+++ b/src/popup/views/settings.js
@@ -16,6 +16,8 @@ const {
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch");
+const { restorableStack } = require("../../shared/persistedState");
+const { resetRenderedViews, markViewRendered } = require("../viewRouter");
const {
log,
debugFetch,
@@ -319,12 +321,33 @@ function init(ctx) {
networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value;
if (newId === state.networkId) return;
+ // The screens beneath Settings show the previous network, so Back
+ // finds them as it does in a reopened popup: each is rendered again
+ // when Back lands on it, and the stack is cut at the first one that
+ // cannot be rendered from saved state, such as Send or AddToken. It
+ // is cut at AddressToken too, unless that shows the native token: any
+ // other token there was picked on the previous network. Without a
+ // wallet the stack starts at Welcome, which that cut would drop, and
+ // Welcome shows nothing from a network. The cut comes before the
+ // switch so that the switch's save keeps it for a popup closed
+ // straight after.
+ if (state.hasWallet) {
+ const tokenView = state.viewStack.indexOf("address-token");
+ if (tokenView !== -1 && state.selectedToken !== "ETH") {
+ state.viewStack = state.viewStack.slice(0, tokenView);
+ }
+ state.viewStack = restorableStack(state.viewStack, "settings");
+ }
const net = await onChainSwitch(newId);
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
+ resetRenderedViews();
+ // Settings itself is brought up to date for the new network here.
+ markViewRendered("settings");
$("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..a420fe0 100644
--- a/src/popup/views/settingsAddToken.js
+++ b/src/popup/views/settingsAddToken.js
@@ -1,6 +1,6 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
-const { state, saveState } = require("../../shared/state");
+const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
@@ -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");
@@ -130,6 +136,9 @@ function init(_ctx) {
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr);
+ // The network the lookup asks, which Settings may switch away from
+ // before it answers, as for a transaction in confirmTx.js.
+ const chainId = currentNetwork().chainId;
try {
const info = await lookupTokenInfo(
addr,
@@ -137,6 +146,15 @@ function init(_ctx) {
state.networkId,
ctx.pageClosed,
);
+ // A contract address names a token on one network only, so an
+ // answer after a switch is not added to the new network's list.
+ if (currentNetwork().chainId !== chainId) {
+ showFlash(
+ "The active network changed before the lookup finished," +
+ " so the token was not added.",
+ );
+ return;
+ }
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,
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/flashLine.test.js b/tests/flashLine.test.js
index c5fdfda..ce00781 100644
--- a/tests/flashLine.test.js
+++ b/tests/flashLine.test.js
@@ -80,6 +80,7 @@ describe.each([
jest.doMock("../src/shared/state", () => ({
state: { trackedTokens: [] },
saveState,
+ currentNetwork: () => ({ chainId: 1 }),
}));
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
diff --git a/tests/persistedFieldContract.test.js b/tests/persistedFieldContract.test.js
index ab7c362..977507c 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..3a56674
--- /dev/null
+++ b/tests/trackedTokensPerNetwork.test.js
@@ -0,0 +1,372 @@
+// 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), including on
+// the screen "Back" lands on after a switch made from the Settings gear and for
+// a contract lookup that answers after one. 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,
+ ADDRESS,
+ TOKEN_ADDRESS,
+} = require("./support/popupBoot");
+const { NETWORKS } = require("../src/shared/networks");
+
+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",
+};
+// Tracked on neither network.
+const NEW_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
+const NETWORK_CHANGED_FLASH =
+ "The active network changed before the lookup finished, so the token was" +
+ " not added.";
+
+afterEach(() => {
+ cleanupPopup();
+});
+
+// The popup reopened onto SettingsAddToken, on `networkId`.
+function bootOnAddToken(networkId, extra) {
+ return bootPopup(
+ unversionedValidProfile({
+ networkId,
+ currentView: "settings-addtoken",
+ ...extra,
+ }),
+ );
+}
+
+// The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked
+// there and TST tracked on Sepolia.
+function bootOnAddress() {
+ return bootPopup(
+ unversionedValidProfile({
+ currentView: "address",
+ selectedWallet: 0,
+ selectedAddress: 0,
+ trackedTokens: [MAINNET_TOKEN],
+ networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
+ }),
+ );
+}
+
+// The popup reopened onto AddressToken for `token`, with AddressDetail beneath
+// it, over the same tracked tokens as bootOnAddress().
+function bootOnToken(token) {
+ return bootPopup(
+ unversionedValidProfile({
+ currentView: "address-token",
+ viewStack: ["main", "address"],
+ selectedWallet: 0,
+ selectedAddress: 0,
+ selectedToken: token,
+ trackedTokens: [MAINNET_TOKEN],
+ networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
+ }),
+ );
+}
+
+// AddressToken for `token` opened from AddressDetail, as tapping the token's
+// row there does.
+async function openToken(boot, token) {
+ const { state } = require("../src/shared/state");
+ state.selectedToken = token;
+ require("../src/popup/views/helpers").pushCurrentView();
+ require("../src/popup/views/addressToken").show();
+ await boot.settle();
+}
+
+// Presses `addButton` with a contract lookup that answers only after the
+// Settings gear and a switch to `networkId`.
+async function addAcrossSwitch(boot, addButton, networkId) {
+ const { lookupTokenInfo } = require("../src/shared/balances");
+ let answer;
+ lookupTokenInfo.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ const adding = boot.click(addButton);
+ await boot.click("btn-settings");
+ await chooseNetwork(boot, networkId);
+ answer({ symbol: "NEW", decimals: 18, name: "New Token" });
+ await adding;
+}
+
+// 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();
+}
+
+// The Settings gear, a switch to Sepolia, and Back.
+async function gearSwitchBack(boot) {
+ await boot.click("btn-settings");
+ expect(boot.visibleViews()).toEqual(["settings"]);
+ await chooseNetwork(boot, "sepolia");
+ await boot.click("btn-settings-back");
+}
+
+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");
+});
+
+describe("Back after a switch from the Settings gear", () => {
+ test("SettingsAddToken no longer offers the common tokens", async () => {
+ const boot = await bootOnAddToken("mainnet");
+ expect(boot.hidden("settings-addtoken-common")).toBe(false);
+
+ await gearSwitchBack(boot);
+ expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
+ expect(boot.hidden("settings-addtoken-common")).toBe(true);
+ });
+
+ test("AddressDetail lists the new network's tracked tokens", async () => {
+ const boot = await bootOnAddress();
+ expect(boot.node("address-balances").innerHTML).toContain("DAI");
+
+ await gearSwitchBack(boot);
+ expect(boot.visibleViews()).toEqual(["address"]);
+ const balances = boot.node("address-balances").innerHTML;
+ expect(balances).toContain("TST");
+ expect(balances).not.toContain("DAI");
+ });
+
+ // Neither can be rendered from saved state, so neither is landed on.
+ test.each([
+ ["btn-add-token", "add-token"],
+ ["btn-send", "send"],
+ ])("from over %s, it lands on AddressDetail", async (button, view) => {
+ const boot = await bootOnAddress();
+ await boot.click(button);
+ expect(boot.visibleViews()).toEqual([view]);
+
+ await gearSwitchBack(boot);
+ expect(boot.visibleViews()).toEqual(["address"]);
+ expect(boot.node("address-balances").innerHTML).not.toContain("DAI");
+ });
+
+ // DAI was picked on Ethereum Mainnet, where alone it is tracked.
+ test.each([
+ [
+ "opened from AddressDetail",
+ async () => {
+ const boot = await bootOnAddress();
+ await openToken(boot, MAINNET_TOKEN.address);
+ return boot;
+ },
+ ],
+ ["in a reopened popup", () => bootOnToken(MAINNET_TOKEN.address)],
+ ])(
+ "from AddressToken for DAI %s, it lands on AddressDetail",
+ async (_how, open) => {
+ const boot = await open();
+ expect(boot.visibleViews()).toEqual(["address-token"]);
+ expect(boot.text("address-token-title")).toContain("DAI");
+
+ await gearSwitchBack(boot);
+ expect(boot.visibleViews()).toEqual(["address"]);
+ const balances = boot.node("address-balances").innerHTML;
+ expect(balances).toContain("TST");
+ expect(balances).not.toContain("DAI");
+ },
+ );
+
+ // The stack is saved cut with the switch, so a popup closed straight after
+ // it and reopened onto Settings lands where this one would. Loading a
+ // saved stack cuts it at AddToken and Send as well, but not at
+ // AddressToken.
+ test.each([
+ [
+ "AddressToken for DAI",
+ (boot) => openToken(boot, MAINNET_TOKEN.address),
+ ],
+ ["AddToken", (boot) => boot.click("btn-add-token")],
+ ["Send", (boot) => boot.click("btn-send")],
+ ])(
+ "from %s, a popup reopened after the switch lands on AddressDetail",
+ async (_view, open) => {
+ const boot = await bootOnAddress();
+ await open(boot);
+ await boot.click("btn-settings");
+ await chooseNetwork(boot, "sepolia");
+
+ const reopened = await bootPopup(boot.storage.read("autistmask"));
+ expect(reopened.visibleViews()).toEqual(["settings"]);
+ await reopened.click("btn-settings-back");
+ expect(reopened.visibleViews()).toEqual(["address"]);
+ const balances = reopened.node("address-balances").innerHTML;
+ expect(balances).toContain("TST");
+ expect(balances).not.toContain("DAI");
+ },
+ );
+
+ test("AddressToken for the native token is drawn again for the new network", async () => {
+ const boot = await bootOnToken("ETH");
+ expect(boot.visibleViews()).toEqual(["address-token"]);
+
+ await gearSwitchBack(boot);
+ expect(boot.visibleViews()).toEqual(["address-token"]);
+ expect(boot.text("address-token-title")).toContain("SepoliaETH");
+ });
+
+ // A popup reopened onto ConfirmTx has no Send beneath it, so "Back" draws
+ // ConfirmTx again from the saved transaction. It is drawn for the network
+ // the transaction was reviewed on, and Sign & Send refuses it on the new
+ // one.
+ test("ConfirmTx reviewed on Sepolia is not sent on Ethereum Mainnet", async () => {
+ const boot = await bootPopup(
+ unversionedValidProfile({
+ networkId: "sepolia",
+ currentView: "confirm-tx",
+ selectedWallet: 0,
+ selectedAddress: 0,
+ viewData: {
+ pendingTx: {
+ token: "ETH",
+ from: ADDRESS,
+ to: ADDRESS,
+ amount: "1",
+ chainId: NETWORKS.sepolia.chainId,
+ },
+ },
+ }),
+ );
+ expect(boot.visibleViews()).toEqual(["confirm-tx"]);
+
+ await boot.click("btn-settings");
+ await chooseNetwork(boot, "mainnet");
+ globalThis.fetch.mockClear();
+ await boot.click("btn-settings-back");
+ expect(boot.visibleViews()).toEqual(["confirm-tx"]);
+ // Drawn again: its Etherscan label check asks Sepolia's explorer.
+ expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
+ NETWORKS.sepolia.explorerUrl + "/address/" + ADDRESS,
+ ]);
+ expect(boot.text("confirm-type")).toBe("Native SepoliaETH transfer");
+
+ boot.node("confirm-tx-password").value = "any password";
+ await boot.click("btn-confirm-send");
+ expect(boot.visibleViews()).toEqual(["confirm-tx"]);
+ expect(boot.node("confirm-errors").innerHTML).toContain(
+ "The active network changed after this transaction was reviewed",
+ );
+ // Refused before the password is even tried.
+ expect(boot.text("confirm-tx-password-error")).toBe("");
+ });
+
+ test("without a wallet, it lands on Welcome", async () => {
+ const boot = await bootPopup(undefined);
+ expect(boot.visibleViews()).toEqual(["welcome"]);
+
+ await gearSwitchBack(boot);
+ expect(boot.visibleViews()).toEqual(["welcome"]);
+ });
+
+ test("a later Back onto Settings keeps what was typed there", async () => {
+ const boot = await bootOnAddress();
+ await boot.click("btn-settings");
+ await chooseNetwork(boot, "sepolia");
+ boot.node("settings-rpc").value = "https://typed.example";
+
+ await boot.click("btn-settings-add-token");
+ expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
+ await boot.click("btn-settings-addtoken-back");
+ expect(boot.visibleViews()).toEqual(["settings"]);
+ expect(boot.value("settings-rpc")).toBe("https://typed.example");
+ });
+});
+
+describe("a contract lookup that answers after a switch adds nothing", () => {
+ test("from SettingsAddToken", async () => {
+ const boot = await bootOnAddToken("sepolia", {
+ networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
+ });
+ const stored = () => boot.storage.read("autistmask");
+ boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
+
+ await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
+ expect(boot.visibleViews()).toEqual(["settings"]);
+ expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
+ expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
+ expect(stored().networkTrackedTokens.sepolia).toEqual([]);
+ });
+
+ test("from AddToken", async () => {
+ const boot = await bootOnAddress();
+ const stored = () => boot.storage.read("autistmask");
+ await boot.click("btn-add-token");
+ boot.node("add-token-address").value = NEW_TOKEN_ADDRESS;
+
+ await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
+ expect(boot.visibleViews()).toEqual(["settings"]);
+ expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
+ expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
+ expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
+ });
+});