diff --git a/README.md b/README.md
index 445096e..f853bbf 100644
--- a/README.md
+++ b/README.md
@@ -1497,6 +1497,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
@@ -1794,19 +1807,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
@@ -1944,13 +1960,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`)
@@ -1961,16 +1982,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
@@ -2015,7 +2039,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`)
@@ -2198,7 +2223,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
@@ -2207,11 +2233,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`)
@@ -2426,8 +2460,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]);
+ });
+});