diff --git a/TODO.md b/TODO.md index 4e04ab4..fd5e7dd 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,28 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-20: A web page can no longer switch the wallet's chain, and switching + no longer destroys the user's endpoints + ([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)). + `wallet_switchEthereumChain` was answered for any origin at all, with no + connection check and no prompt: any page could clear the `[TESTNET]` banner + under a user who believed they were on Sepolia. It now takes the same + `allowedSites`/`connectedSites` gate the signing methods take, ahead of the + same-chain and unsupported-chain answers, and refuses an unconnected origin + with `4100`. The switch itself also overwrote `state.rpcUrl` and + `state.blockscoutUrl` with the network defaults, so a user running their own + node lost that url permanently and silently to a public endpoint that then + sees every address they hold. Endpoints are now remembered per network in + `state.networkEndpoints`, snapshotted from the network being left and restored + for the network being entered; `state.rpcUrl` stays the live value for the + active network, so no reader changed. A profile written before the map existed + has its stored pair adopted for the network it was stored under, and loses + nothing. The handler now loads state before it switches + ([#316](https://git.eeqj.de/sneak/AutistMask/issues/316)): the service worker + populates nothing at module scope, so a worker revived by the page's own + message held `DEFAULT_STATE`, and the switch persisted every field of it — + wiping every wallet, every site approval and every tracked token from storage + along with the endpoint. - 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed ([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation screen renders from the block explorer's cached decimals; the transfer was diff --git a/src/background/index.js b/src/background/index.js index 21a0302..58c349e 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -672,6 +672,34 @@ async function handleRpc(method, params, origin) { } if (method === "wallet_switchEthereumChain") { + // Gated exactly like the signing methods, and gated before the + // same-chain early return. Switching the chain is wallet-wide: it + // moves the network the popup shows and the endpoints every other + // tab is served from, so a page the user never connected to must + // not be able to do it. Ungated, any page could clear the + // [TESTNET] banner under a user who believed they were on Sepolia. + const s = await getState(); + const activeAddress = await getActiveAddress(); + const hostname = extractHostname(origin); + const allowed = s.allowedSites[activeAddress] || []; + if ( + !allowed.includes(hostname) && + !connectedSites[origin + ":" + activeAddress] + ) { + return { error: { code: 4100, message: "Unauthorized" } }; + } + + // onChainSwitch() mutates the module-level state singleton and then + // saves every field of it, and currentNetwork() reads the same + // singleton. This worker may have been started by this very message: + // nothing loads state at module scope, so without this the singleton + // is DEFAULT_STATE, the same-chain check compares against the wrong + // network, and the save writes empty wallets, empty allowedSites and + // the default endpoints over the user's stored profile + // (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent + // as the transaction path below. + await loadState(); + const chainId = params?.[0]?.chainId; if (chainId === currentNetwork().chainId) { return { result: null }; diff --git a/src/shared/chainSwitch.js b/src/shared/chainSwitch.js index 96d7bd3..feeda38 100644 --- a/src/shared/chainSwitch.js +++ b/src/shared/chainSwitch.js @@ -19,9 +19,26 @@ async function onChainSwitch(newNetworkId) { const net = networkById(newNetworkId); // --- core identity --- + // Endpoints are remembered per network rather than reset to the + // defaults, because a user who points the wallet at their own node has + // no way to get that URL back once it is gone: overwriting it moved + // every address and every transaction onto a third-party endpoint + // silently and permanently. + // + // state.rpcUrl / state.blockscoutUrl stay the live endpoints of the + // active network, so nothing that reads them changes. The invariant is + // that for the ACTIVE network those two fields are authoritative and + // the map entry may be stale (Settings writes the fields directly); + // for every other network the map is authoritative. Snapshotting the + // outgoing network here, before the switch, is what reconciles them. + state.networkEndpoints[state.networkId] = { + rpcUrl: state.rpcUrl, + blockscoutUrl: state.blockscoutUrl, + }; + const remembered = state.networkEndpoints[net.id] || {}; state.networkId = net.id; - state.rpcUrl = net.defaultRpcUrl; - state.blockscoutUrl = net.defaultBlockscoutUrl; + state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl; + state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl; // --- price cache --- // Prices are chain-specific (testnet tokens are worthless, diff --git a/src/shared/state.js b/src/shared/state.js index c7614a2..a774e6b 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -14,6 +14,11 @@ const DEFAULT_STATE = { networkId: "mainnet", rpcUrl: DEFAULT_RPC_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, + // Endpoints remembered per network: { [networkId]: { rpcUrl, + // blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints + // of the active network; this is what the others are restored from + // when the active network changes. See onChainSwitch(). + networkEndpoints: {}, lastBalanceRefresh: 0, activeAddress: null, allowedSites: {}, @@ -34,6 +39,9 @@ const DEFAULT_STATE = { const state = { ...DEFAULT_STATE, + // Its own object, not the one DEFAULT_STATE holds: onChainSwitch() + // mutates this map in place, and a spread copies the reference. + networkEndpoints: {}, currentView: null, selectedWallet: null, selectedAddress: null, @@ -88,6 +96,7 @@ async function saveState() { networkId: state.networkId, rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl, + networkEndpoints: state.networkEndpoints, lastBalanceRefresh: state.lastBalanceRefresh, activeAddress: state.activeAddress, allowedSites: state.allowedSites, @@ -128,6 +137,30 @@ async function loadState() { state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; + // An actual object is required, not merely a truthy non-array: the + // code below and onChainSwitch() index and ASSIGN INTO this value, + // and assigning a property to a string or a number is a silent no-op + // in sloppy mode. A stored primitive would therefore be re-persisted + // unchanged forever, and every switch would fall back to the network + // default — the endpoint loss this map exists to prevent, with no + // self-healing. The allowedSites/deniedSites guards below are only + // read from, which is why they can be looser. + state.networkEndpoints = + typeof saved.networkEndpoints === "object" && + saved.networkEndpoints !== null && + !Array.isArray(saved.networkEndpoints) + ? saved.networkEndpoints + : {}; + // A profile written before this map existed carries exactly one pair + // of endpoints, belonging to whatever network it was last on. Adopt + // it as that network's remembered pair, so a custom endpoint set on + // the old build is not lost by the first switch away and back. + if (!state.networkEndpoints[state.networkId]) { + state.networkEndpoints[state.networkId] = { + rpcUrl: state.rpcUrl, + blockscoutUrl: state.blockscoutUrl, + }; + } state.lastBalanceRefresh = saved.lastBalanceRefresh || 0; state.activeAddress = saved.activeAddress || null; state.allowedSites = diff --git a/tests/chainSwitchGate.test.js b/tests/chainSwitchGate.test.js new file mode 100644 index 0000000..f2213bc --- /dev/null +++ b/tests/chainSwitchGate.test.js @@ -0,0 +1,232 @@ +// Who may move the active chain. +// +// wallet_switchEthereumChain used to be answered for any origin at all, with +// no connection check and no prompt, so a page the user had never connected +// to could clear the [TESTNET] banner under someone who believed they were +// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal +// is asserted as a refusal to ACT — the state unmoved and no chainChanged +// broadcast — because an error code alone would not distinguish a gate from +// a switch that happened and then reported a failure. +// +// The endpoint half of that issue lives in tests/networkEndpoints.test.js; +// this file mocks the state module, which that one exercises for real. + +const { networkById } = require("../src/shared/networks"); + +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; + +// The site the persisted state has connected, and one it has never heard of. +const CONNECTED_ORIGIN = "https://dapp.example"; +const CONNECTED_HOSTNAME = "dapp.example"; +const STRANGER_ORIGIN = "https://stranger.example"; + +const MAINNET = networkById("mainnet"); +const SEPOLIA = networkById("sepolia"); + +// The user's own node, so a switch that happens is visible as the loss of it. +const CUSTOM_RPC = "http://127.0.0.1:8545"; + +function walletFixture() { + return [ + { + name: "Wallet 1", + type: "hd", + addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], + }, + ]; +} + +// Let the handler's promise chain run to the next suspension point. The gate +// reads storage before it answers, so the response is several awaits deep. +async function settle() { + for (let i = 0; i < 50; i++) await Promise.resolve(); +} + +afterEach(() => { + delete global.chrome; +}); + +// --------------------------------------------------------------------------- +// The gate: which origins the background will switch the chain for. +// --------------------------------------------------------------------------- + +// Load the background worker against stubbed browser APIs, with the real +// chain-switch module behind it, and return the handles to drive it. The +// wallet state is a plain object so that a switch that DID happen is visible +// as a mutation of it, and one that did not is visible as its absence. +function loadBackground() { + jest.resetModules(); + + const walletState = { + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: MAINNET.defaultBlockscoutUrl, + networkEndpoints: {}, + wallets: walletFixture(), + lastBalanceRefresh: 1, + tokenHolderCache: {}, + fraudContracts: [], + }; + + jest.doMock("../src/shared/state", () => ({ + state: walletState, + loadState: jest.fn(async () => {}), + saveState: jest.fn(async () => {}), + currentNetwork: () => networkById(walletState.networkId), + })); + jest.doMock("../src/shared/balances", () => ({ + getProvider: () => ({}), + refreshBalances: jest.fn(async () => {}), + })); + jest.doMock("../src/shared/phishingDomains", () => ({ + isPhishingDomain: () => false, + })); + jest.doMock("../src/shared/alarms", () => ({ + BALANCE_REFRESH_ALARM: "balance", + BALANCE_REFRESH_PERIOD_MINUTES: 1, + ensureRecurringAlarms: jest.fn(async () => {}), + registerAlarmHandlers: jest.fn(), + })); + + const persisted = { + wallets: walletFixture(), + activeAddress: ADDRESS, + allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] }, + deniedSites: {}, + }; + + let messageListener = null; + // Every message the background pushed at a content script. chainChanged + // is what tells a page the wallet moved, so an ungated switch is visible + // here as well as in the state. + const toTabs = []; + + global.chrome = { + storage: { + local: { + get: jest.fn(async () => ({ autistmask: persisted })), + set: jest.fn(async () => {}), + }, + }, + runtime: { + getURL: (path) => "chrome-extension://autistmask/" + path, + onMessage: { + addListener: (fn) => { + messageListener = fn; + }, + }, + onConnect: { addListener: () => {} }, + lastError: null, + }, + windows: { + getLastFocused: (cb) => cb(null), + create: (options, cb) => cb({ id: 1 }), + remove: (id, cb) => { + if (cb) cb(); + }, + onRemoved: { addListener: () => {} }, + }, + tabs: { + query: (queryInfo, cb) => cb([{ id: 1 }]), + sendMessage: (tabId, message, cb) => { + toTabs.push(message); + if (cb) cb(); + }, + }, + action: { setPopup: () => {} }, + }; + + require("../src/background/index"); + + async function switchChain(chainId, origin) { + let result = null; + messageListener( + { + type: "AUTISTMASK_RPC", + method: "wallet_switchEthereumChain", + params: [{ chainId }], + }, + { origin }, + (r) => { + result = r; + }, + ); + await settle(); + return result; + } + + return { + switchChain, + walletState, + chainChangedEvents: () => + toTabs.filter((m) => m.eventName === "chainChanged"), + }; +} + +describe("wallet_switchEthereumChain is gated on the connection", () => { + test("an origin the wallet was never connected to is refused with 4100", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN); + + expect(result.error).toEqual({ code: 4100, message: "Unauthorized" }); + expect(result.result).toBeUndefined(); + // The refusal has to be a refusal to ACT, not just an error string: + // the wallet is still on mainnet, still on the user's own node, and + // no page was told the chain moved. + expect(bg.walletState.networkId).toBe("mainnet"); + expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC); + expect(bg.chainChangedEvents()).toEqual([]); + }); + + test("an unconnected origin is refused even for the chain already active", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN); + + expect(result.error).toEqual({ code: 4100, message: "Unauthorized" }); + }); + + test("an unconnected origin is refused before the unsupported-chain answer", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain("0x89", STRANGER_ORIGIN); + + expect(result.error.code).toBe(4100); + }); + + test("a connected origin switches the chain", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); + + expect(result).toEqual({ result: null }); + expect(bg.walletState.networkId).toBe("sepolia"); + expect(bg.chainChangedEvents()).toEqual([ + { + type: "AUTISTMASK_EVENT", + eventName: "chainChanged", + data: SEPOLIA.chainId, + }, + ]); + }); + + test("a connected origin asking for an unsupported chain still gets 4902", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain("0x89", CONNECTED_ORIGIN); + + expect(result.error.code).toBe(4902); + expect(bg.walletState.networkId).toBe("mainnet"); + }); + + test("a switch by a connected origin keeps the user's endpoint", async () => { + const bg = loadBackground(); + + await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); + expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); + + await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN); + expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC); + }); +}); diff --git a/tests/coldWorkerChainSwitch.test.js b/tests/coldWorkerChainSwitch.test.js new file mode 100644 index 0000000..36f2dbf --- /dev/null +++ b/tests/coldWorkerChainSwitch.test.js @@ -0,0 +1,212 @@ +// What a chain switch does to a worker that has not loaded state yet. +// +// The MV3 service worker is terminated when idle and revived by the next +// message, and nothing loads state at module scope. The chain-switch handler +// reaches onChainSwitch(), which mutates the module-level `state` singleton +// and then persists EVERY field of it, so a handler that runs before a load +// writes DEFAULT_STATE over the user's stored profile — every wallet, every +// site approval, every tracked token and the custom endpoint +// (https://git.eeqj.de/sneak/AutistMask/issues/316). The same singleton is +// what currentNetwork() answers from, so the same-chain early return also +// compares against the wrong network. +// +// This file therefore uses the REAL state module and never calls loadState() +// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the +// state module wholesale and tests/networkEndpoints.test.js always loads +// first, so neither can see this. + +const { networkById } = require("../src/shared/networks"); + +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; + +const CONNECTED_ORIGIN = "https://dapp.example"; +const CONNECTED_HOSTNAME = "dapp.example"; + +const MAINNET = networkById("mainnet"); +const SEPOLIA = networkById("sepolia"); + +// The user's own node, and a wallet whose loss is the whole point. +const CUSTOM_RPC = "http://127.0.0.1:8545"; +const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2"; +const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; + +function walletFixture() { + return [ + { + name: "Wallet 1", + type: "hd", + addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], + }, + ]; +} + +// A profile as an installed extension holds it, on `networkId`. +function storedProfile(networkId) { + return { + hasWallet: true, + wallets: walletFixture(), + activeAddress: ADDRESS, + networkId, + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] }, + deniedSites: {}, + trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }], + theme: "dark", + }; +} + +async function settle() { + for (let i = 0; i < 50; i++) await Promise.resolve(); +} + +afterEach(() => { + delete global.chrome; +}); + +// Load the background worker with the real state and chain-switch modules +// behind it, over a storage stub that actually keeps what is written — a +// wipe is only observable against storage that remembers. +function loadColdWorker(networkId) { + jest.resetModules(); + + jest.doMock("../src/shared/balances", () => ({ + getProvider: () => ({}), + refreshBalances: jest.fn(async () => {}), + })); + jest.doMock("../src/shared/phishingDomains", () => ({ + isPhishingDomain: () => false, + })); + jest.doMock("../src/shared/alarms", () => ({ + BALANCE_REFRESH_ALARM: "balance", + BALANCE_REFRESH_PERIOD_MINUTES: 1, + ensureRecurringAlarms: jest.fn(async () => {}), + registerAlarmHandlers: jest.fn(), + })); + + const store = { autistmask: storedProfile(networkId) }; + + let messageListener = null; + const toTabs = []; + + global.chrome = { + storage: { + local: { + get: jest.fn(async () => ({ autistmask: store.autistmask })), + set: jest.fn(async (items) => { + store.autistmask = items.autistmask; + }), + }, + }, + runtime: { + getURL: (path) => "chrome-extension://autistmask/" + path, + onMessage: { + addListener: (fn) => { + messageListener = fn; + }, + }, + onConnect: { addListener: () => {} }, + lastError: null, + }, + windows: { + getLastFocused: (cb) => cb(null), + create: (options, cb) => cb({ id: 1 }), + remove: (id, cb) => { + if (cb) cb(); + }, + onRemoved: { addListener: () => {} }, + }, + tabs: { + query: (queryInfo, cb) => cb([{ id: 1 }]), + sendMessage: (tabId, message, cb) => { + toTabs.push(message); + if (cb) cb(); + }, + }, + action: { setPopup: () => {} }, + }; + + require("../src/background/index"); + + async function switchChain(chainId) { + let result = null; + messageListener( + { + type: "AUTISTMASK_RPC", + method: "wallet_switchEthereumChain", + params: [{ chainId }], + }, + { origin: CONNECTED_ORIGIN }, + (r) => { + result = r; + }, + ); + await settle(); + return result; + } + + return { + switchChain, + persisted: () => store.autistmask, + chainChangedEvents: () => + toTabs.filter((m) => m.eventName === "chainChanged"), + }; +} + +describe("a chain switch on a worker that never loaded state", () => { + test("keeps the wallets, approvals, tokens and custom endpoint", async () => { + const bg = loadColdWorker("mainnet"); + + const result = await bg.switchChain(SEPOLIA.chainId); + expect(result).toEqual({ result: null }); + + const after = bg.persisted(); + // The switch itself happened. + expect(after.networkId).toBe("sepolia"); + 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 + // 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_HOSTNAME] }); + expect(after.trackedTokens).toEqual([ + { address: TOKEN, symbol: "DAI", decimals: 18 }, + ]); + expect(after.theme).toBe("dark"); + + // The user's mainnet endpoint is remembered rather than replaced by + // the public default, so switching back returns it. + expect(after.networkEndpoints.mainnet).toEqual({ + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }); + + await bg.switchChain(MAINNET.chainId); + expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC); + expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + expect(bg.persisted().wallets).toEqual(walletFixture()); + }); + + test("compares the requested chain against the stored one, not the default", async () => { + // Stored on Sepolia, asked for mainnet. Reading the unloaded + // singleton makes this look like the chain already active, so the + // page is told the switch succeeded while the wallet stays on the + // testnet it was on. + const bg = loadColdWorker("sepolia"); + + const result = await bg.switchChain(MAINNET.chainId); + expect(result).toEqual({ result: null }); + + expect(bg.persisted().networkId).toBe("mainnet"); + expect(bg.chainChangedEvents()).toEqual([ + { + type: "AUTISTMASK_EVENT", + eventName: "chainChanged", + data: MAINNET.chainId, + }, + ]); + }); +}); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index d8b925e..434f023 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -1193,8 +1193,9 @@ test("the theme and network selectors carry a non-default persisted value (#229) // and a selector stuck on `dark`/`sepolia` would otherwise be // indistinguishable here from one that persists correctly. Switching // the network back also returns state.rpcUrl and state.blockscoutUrl - // to the mainnet defaults that onChainSwitch() overwrote, which are - // the values src/shared/state.js starts with. + // to the mainnet endpoints onChainSwitch() remembered, which this + // fixture never customised and so are the mainnet defaults + // src/shared/state.js starts with. await env.page.selectOption("#settings-theme", "system"); await env.page.selectOption("#settings-network", "mainnet"); diff --git a/tests/networkEndpoints.test.js b/tests/networkEndpoints.test.js new file mode 100644 index 0000000..8ef4388 --- /dev/null +++ b/tests/networkEndpoints.test.js @@ -0,0 +1,192 @@ +// What a chain switch is allowed to do to the endpoints the user configured. +// +// A switch used to overwrite state.rpcUrl and state.blockscoutUrl with the +// network defaults, so a user pointing the wallet at their own node lost that +// url the first time anything switched chains — with no notification and no +// way to recover it, having been moved onto a public endpoint that then sees +// every address they hold (https://git.eeqj.de/sneak/AutistMask/issues/308). +// Endpoints are now remembered per network, which is why the round trips +// below assert the ORIGINAL url comes back rather than only that the switch +// happened. + +const { networkById } = require("../src/shared/networks"); + +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; + +const MAINNET = networkById("mainnet"); +const SEPOLIA = networkById("sepolia"); + +// The user's own node: the pair the switch used to throw away. +const CUSTOM_RPC = "http://127.0.0.1:8545"; +const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2"; + +function walletFixture() { + return [ + { + name: "Wallet 1", + type: "hd", + addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], + }, + ]; +} + +// The real state module against stubbed storage, plus whatever the last +// saveState() wrote — so a case can reload a fresh module from the bytes an +// earlier one persisted, which is what an extension restart does. `state` is +// a module-level singleton, so the registry has to be reset per load. +function loadModuleWith(persisted) { + jest.resetModules(); + let written = null; + global.chrome = { + storage: { + local: { + get: jest.fn(async () => + persisted ? { autistmask: persisted } : {}, + ), + set: jest.fn(async (items) => { + written = items.autistmask; + }), + }, + }, + }; + return { + mod: require("../src/shared/state"), + chainSwitch: require("../src/shared/chainSwitch"), + written: () => written, + }; +} + +afterEach(() => { + delete global.chrome; +}); + +describe("a custom endpoint survives a chain switch", () => { + test("switching away and back restores the user's rpc and blockscout urls", async () => { + const { mod, chainSwitch } = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + networkEndpoints: { + mainnet: { + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }, + }, + }); + await mod.loadState(); + + await chainSwitch.onChainSwitch("sepolia"); + // The new chain gets its own endpoints, not the ones belonging to the + // chain just left: a mainnet node cannot answer for Sepolia. + expect(mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); + expect(mod.state.blockscoutUrl).toBe(SEPOLIA.defaultBlockscoutUrl); + + await chainSwitch.onChainSwitch("mainnet"); + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + }); + + test("an endpoint set on the network being left is remembered, not lost", async () => { + const { mod, chainSwitch } = loadModuleWith({ + wallets: walletFixture(), + networkId: "sepolia", + rpcUrl: SEPOLIA.defaultRpcUrl, + blockscoutUrl: SEPOLIA.defaultBlockscoutUrl, + networkEndpoints: {}, + }); + await mod.loadState(); + + // What the Settings screen does: write the live field, then save. The + // map entry for the active network is stale until the switch, which + // is what snapshotting the outgoing network exists to reconcile. + mod.state.rpcUrl = CUSTOM_RPC; + await mod.saveState(); + + await chainSwitch.onChainSwitch("mainnet"); + expect(mod.state.rpcUrl).toBe(MAINNET.defaultRpcUrl); + + await chainSwitch.onChainSwitch("sepolia"); + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + }); + + test("the remembered endpoints survive an extension restart", async () => { + const first = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }); + await first.mod.loadState(); + await first.chainSwitch.onChainSwitch("sepolia"); + + // Reload from exactly the bytes the switch persisted. + const second = loadModuleWith(first.written()); + await second.mod.loadState(); + expect(second.mod.state.networkId).toBe("sepolia"); + expect(second.mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); + + await second.chainSwitch.onChainSwitch("mainnet"); + expect(second.mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(second.mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + }); + + test("a profile written before networkEndpoints existed keeps its endpoint", async () => { + // Exactly the stored shape the current release writes: one pair of + // urls and no map. It is adopted as the remembered pair of the + // network it was stored under. + const { mod, chainSwitch } = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }); + await mod.loadState(); + + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(mod.state.networkEndpoints).toEqual({ + mainnet: { rpcUrl: CUSTOM_RPC, blockscoutUrl: CUSTOM_BLOCKSCOUT }, + }); + + await chainSwitch.onChainSwitch("sepolia"); + await chainSwitch.onChainSwitch("mainnet"); + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + }); + + // A primitive is the dangerous case, not the array: assigning a property + // to a string throws nothing and stores nothing, so a stored string would + // be carried through loadState() and re-persisted by every save, and each + // switch would fall back to the public default in place of the user's + // endpoint, permanently. + test.each([ + ["an array", ["not", "a", "map"]], + ["a string", "junk"], + ["a number", 7], + ])("a stored networkEndpoints that is %s is discarded", async (_, bad) => { + const { mod, chainSwitch } = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + networkEndpoints: bad, + }); + await mod.loadState(); + + // Discarded, then seeded from the live endpoints the same way an old + // profile is — never left as something onChainSwitch() would index. + expect(mod.state.networkEndpoints).toEqual({ + mainnet: { + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }, + }); + + // And the endpoint really survives the round trip, which is the point + // of discarding it rather than only of the shape being right. + await chainSwitch.onChainSwitch("sepolia"); + await chainSwitch.onChainSwitch("mainnet"); + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + }); +});