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