diff --git a/README.md b/README.md
index a7b69e0..ebe175e 100644
--- a/README.md
+++ b/README.md
@@ -1494,6 +1494,15 @@ 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.
+"Back" then renders the screen it lands on for the new network, 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
@@ -1924,7 +1933,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
@@ -1941,16 +1951,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
@@ -1995,7 +2008,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`)
@@ -2174,7 +2188,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
@@ -2183,8 +2198,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
@@ -2402,8 +2421,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 3441682..92b02e7 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -838,7 +838,7 @@
id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
>
-
+
@@ -1321,36 +1321,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 ed7def4..5a8d4d4 100644
--- a/src/popup/viewRouter.js
+++ b/src/popup/viewRouter.js
@@ -37,9 +37,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();
}
@@ -213,9 +215,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..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..5326230 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,
@@ -323,8 +325,21 @@ function init(ctx) {
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
+ // 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.
+ // Without a wallet the stack starts at Welcome, which that cut would
+ // drop, and Welcome shows nothing from a network.
+ if (state.hasWallet) {
+ state.viewStack = restorableStack(state.viewStack, "settings");
+ }
+ 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..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..f69934c
--- /dev/null
+++ b/tests/trackedTokensPerNetwork.test.js
@@ -0,0 +1,189 @@
+// 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. 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",
+};
+
+// Fake timers hold the timer of the flash message a switch ends with, which
+// would otherwise fire after cleanupPopup() has removed the page and throw.
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ cleanupPopup();
+ jest.useRealTimers();
+});
+
+// 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] },
+ }),
+ );
+}
+
+// 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");
+ });
+
+ 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");
+ });
+});