Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults. The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives. Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does. closes #320
188 lines
7.5 KiB
JavaScript
188 lines
7.5 KiB
JavaScript
// 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 { makeStorageStub } = require("./support/storageStub");
|
|
|
|
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.
|
|
// The stub clones in both directions, as the real chrome.storage.local does.
|
|
// It used to alias, and written() then handed the NEXT module load the live
|
|
// in-memory object of the previous one as its "persisted bytes" — an extension
|
|
// restart that never crossed a serialization boundary. See
|
|
// tests/support/storageStub.js.
|
|
function loadModuleWith(persisted) {
|
|
jest.resetModules();
|
|
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
|
global.chrome = { storage };
|
|
return {
|
|
mod: require("../src/shared/state"),
|
|
chainSwitch: require("../src/shared/chainSwitch"),
|
|
written: () => storage.read("autistmask"),
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|