wallet_switchEthereumChain was answered for any origin at all, with no connection check and no prompt, so any page could move the active chain and clear the [TESTNET] banner under a user who believed they were on Sepolia. It now takes the same allowedSites check the signing methods take and returns 4100 for an unconnected origin. The handler also awaits loadState() before it reads or moves the network. The MV3 worker populates nothing at module scope, so a worker revived by the page's own message held DEFAULT_STATE: the same-chain check compared against the wrong network, and the save wrote empty wallets, empty allowedSites and default endpoints over the user's stored profile, destroying every wallet in the extension. Also fixes #316. Endpoints are now remembered per network in a persisted networkEndpoints map, so a user running a local or private node no longer loses that url permanently to a public endpoint on every switch. A stored map must be an actual object; a primitive previously survived the load and made every switch fall back to the public default with no self-healing. Verified failing first: dropping only the added loadState() fails exactly the two cold-worker cases; reverting only the type guard fails exactly the string and number cases. Reverting both source files to next gives 12 failed / 751 passed.
233 lines
7.8 KiB
JavaScript
233 lines
7.8 KiB
JavaScript
// 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);
|
|
});
|
|
});
|