// 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); }); test("a stored networkEndpoints of the wrong type is discarded", async () => { const { mod } = loadModuleWith({ wallets: walletFixture(), networkId: "mainnet", networkEndpoints: ["not", "a", "map"], }); 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: MAINNET.defaultRpcUrl, blockscoutUrl: MAINNET.defaultBlockscoutUrl, }, }); }); });