fix: gate the chain switch and remember endpoints per network (closes #308)
wallet_switchEthereumChain was answered for any origin at all, with no connection check and no prompt, so a page the user had never connected to could move the active chain — clearing the [TESTNET] banner under someone 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 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: the switch snapshots the network being left and restores the network being entered, falling back to that network's defaults. state.rpcUrl and state.blockscoutUrl remain the live endpoints of the active network, so no reader changed; for the active network they are authoritative and the map entry may be stale, and the snapshot is what reconciles them. A profile written before the map existed has its stored pair adopted for the network it was stored under, so nothing is lost on first load. Neither half held without loading state first. onChainSwitch() mutates the module-level state singleton and persists every field of it, and currentNetwork() reads the same singleton, but the service worker populates nothing at module scope — a worker revived by the page's own message held DEFAULT_STATE, so the same-chain check compared against the wrong network and the save wrote empty wallets, empty allowedSites, no tracked tokens and the default endpoints over the user's stored profile. The handler now awaits loadState() after the gate, as the transaction path already does. A stored networkEndpoints must now be an actual object. The previous guard discarded only falsy values and arrays, so a stored primitive survived the load, the seeding assignment silently no-opped on it, saveState() re-persisted it, and every switch fell back to the public default in place of the user's endpoint — permanently, with no self-healing.
This commit is contained in:
192
tests/networkEndpoints.test.js
Normal file
192
tests/networkEndpoints.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user