A connected site's wallet_switchEthereumChain request for the other supported network now opens a prompt in its own window, through the existing approval machinery, naming the site and both networks. The network, endpoints, balances and caches change, and chainChanged is sent, only when the user approves it; rejecting or closing the prompt answers 4001. One such prompt per site at a time; a request for the active network needs none. The approval window no longer shows the connection prompt while it waits for the approval's description, since both prompts answer on the same port. Model: opus-5-5
393 lines
14 KiB
JavaScript
393 lines
14 KiB
JavaScript
// Who may move the active chain, and when.
|
|
//
|
|
// 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). Gated on the
|
|
// connection, a connected site could still move the wallet between mainnet and
|
|
// Sepolia without asking. Only the user switches the network: a connected
|
|
// site's request opens a prompt, and nothing changes unless the user approves
|
|
// it there (https://git.eeqj.de/sneak/AutistMask/issues/408).
|
|
//
|
|
// Every refusal is asserted as a refusal to ACT — the stored record unmoved
|
|
// and no chainChanged broadcast — because an error code alone would not
|
|
// distinguish a refusal from a switch that happened and then reported a
|
|
// failure.
|
|
//
|
|
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
|
|
// covers the popup's chain switch; this file covers the background's, which
|
|
// goes through storage rather than the shared state singleton.
|
|
|
|
const { networkById } = require("../src/shared/networks");
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
// The site the persisted state has connected, and one it has never heard of.
|
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
|
const STRANGER_ORIGIN = "https://stranger.example";
|
|
|
|
const EXT_URL = "chrome-extension://autistmask/";
|
|
|
|
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";
|
|
|
|
// A balance a switch would clear, so a switch that happens is visible here too.
|
|
function walletFixture() {
|
|
return [
|
|
{
|
|
name: "Wallet 1",
|
|
type: "hd",
|
|
addresses: [
|
|
{ address: ADDRESS, balance: "1.5", 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;
|
|
});
|
|
|
|
// Load the background worker against stubbed browser APIs, with the real
|
|
// chain-switch and persistence modules behind it, and return the handles to
|
|
// drive it.
|
|
function loadBackground() {
|
|
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(),
|
|
}));
|
|
|
|
// Storage is the only wallet state there is. The background reads and
|
|
// writes it per call — it holds no in-memory copy and cannot reach the
|
|
// shared singleton — so a switch that happened is visible here as a
|
|
// written record, and one that did not is visible as its absence.
|
|
const persisted = {
|
|
networkId: "mainnet",
|
|
rpcUrl: CUSTOM_RPC,
|
|
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
|
|
networkEndpoints: {},
|
|
wallets: walletFixture(),
|
|
lastBalanceRefresh: 1,
|
|
tokenHolderCache: {},
|
|
fraudContracts: [],
|
|
activeAddress: ADDRESS,
|
|
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
|
deniedSites: {},
|
|
};
|
|
const storage = makeStorageStub({ autistmask: persisted });
|
|
|
|
let messageListener = null;
|
|
let connectListener = null;
|
|
let windowRemovedListener = null;
|
|
// The URL of every approval window the background opened. The approval id
|
|
// is in it, and that is how the popup learns which approval it answers.
|
|
const opened = [];
|
|
// Every message the background pushed at a content script. chainChanged
|
|
// is what tells a page the wallet moved, so a switch is visible here as
|
|
// well as in the state.
|
|
const toTabs = [];
|
|
|
|
global.chrome = {
|
|
storage,
|
|
runtime: {
|
|
getURL: (path) => EXT_URL + path,
|
|
onMessage: {
|
|
addListener: (fn) => {
|
|
messageListener = fn;
|
|
},
|
|
},
|
|
onConnect: {
|
|
addListener: (fn) => {
|
|
connectListener = fn;
|
|
},
|
|
},
|
|
lastError: null,
|
|
},
|
|
windows: {
|
|
getLastFocused: (cb) => cb(null),
|
|
create: (options, cb) => {
|
|
opened.push(options.url);
|
|
cb({ id: opened.length });
|
|
},
|
|
remove: (id, cb) => {
|
|
if (cb) cb();
|
|
},
|
|
onRemoved: {
|
|
addListener: (fn) => {
|
|
windowRemovedListener = fn;
|
|
},
|
|
},
|
|
},
|
|
tabs: {
|
|
query: (queryInfo, cb) => cb([{ id: 1 }]),
|
|
sendMessage: (tabId, message, cb) => {
|
|
toTabs.push(message);
|
|
if (cb) cb();
|
|
},
|
|
},
|
|
action: { setPopup: () => {} },
|
|
};
|
|
|
|
require("../src/background/index");
|
|
|
|
// A page's request. Its answer is read with result(), which is null for as
|
|
// long as the request is waiting on the user.
|
|
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: () => result };
|
|
}
|
|
|
|
function promptId() {
|
|
return new URL(opened[opened.length - 1]).searchParams.get("approval");
|
|
}
|
|
|
|
// What the popup is told to show for the prompt.
|
|
function describePrompt() {
|
|
let reply = null;
|
|
messageListener(
|
|
{ type: "AUTISTMASK_GET_APPROVAL", id: promptId() },
|
|
{ url: EXT_URL + "src/popup/index.html" },
|
|
(r) => {
|
|
reply = r;
|
|
},
|
|
);
|
|
return reply;
|
|
}
|
|
|
|
// The user's answer, as the popup sends it: on the port named for the
|
|
// approval, from the extension's own page, and then the window closes.
|
|
async function answerPrompt(approved) {
|
|
const onMessage = [];
|
|
const onDisconnect = [];
|
|
const port = {
|
|
name: "approval:" + promptId(),
|
|
sender: { url: EXT_URL + "src/popup/index.html" },
|
|
onMessage: { addListener: (fn) => onMessage.push(fn) },
|
|
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
|
|
};
|
|
connectListener(port);
|
|
for (const fn of onMessage) {
|
|
fn(
|
|
{
|
|
type: "AUTISTMASK_APPROVAL_DECISION",
|
|
approved,
|
|
remember: false,
|
|
},
|
|
port,
|
|
);
|
|
}
|
|
for (const fn of onDisconnect) fn(port);
|
|
await settle();
|
|
}
|
|
|
|
// The user closes the prompt window without answering it.
|
|
async function closePrompt() {
|
|
windowRemovedListener(opened.length);
|
|
await settle();
|
|
}
|
|
|
|
return {
|
|
switchChain,
|
|
describePrompt,
|
|
answerPrompt,
|
|
closePrompt,
|
|
opened,
|
|
walletState: () => storage.read("autistmask"),
|
|
chainChangedEvents: () =>
|
|
toTabs.filter((m) => m.eventName === "chainChanged"),
|
|
};
|
|
}
|
|
|
|
const USER_REJECTED = { code: 4001, message: "User rejected the request." };
|
|
|
|
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 before = bg.walletState();
|
|
|
|
const request = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
|
|
|
|
expect(request.result().error).toEqual({
|
|
code: 4100,
|
|
message: "Unauthorized",
|
|
});
|
|
expect(request.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. Nor was the user asked.
|
|
expect(bg.walletState()).toEqual(before);
|
|
expect(bg.chainChangedEvents()).toEqual([]);
|
|
expect(bg.opened).toEqual([]);
|
|
});
|
|
|
|
test("an unconnected origin is refused even for the chain already active", async () => {
|
|
const bg = loadBackground();
|
|
|
|
const request = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
|
|
|
|
expect(request.result().error).toEqual({
|
|
code: 4100,
|
|
message: "Unauthorized",
|
|
});
|
|
});
|
|
|
|
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
|
|
const bg = loadBackground();
|
|
|
|
const request = await bg.switchChain("0x89", STRANGER_ORIGIN);
|
|
|
|
expect(request.result().error.code).toBe(4100);
|
|
});
|
|
});
|
|
|
|
describe("only the user switches the network", () => {
|
|
test("a connected site's request changes nothing while the prompt is open", async () => {
|
|
const bg = loadBackground();
|
|
const before = bg.walletState();
|
|
|
|
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
|
|
// Waiting on the user, with one prompt on screen naming the site and
|
|
// both networks.
|
|
expect(request.result()).toBeNull();
|
|
expect(bg.opened).toHaveLength(1);
|
|
expect(bg.describePrompt()).toMatchObject({
|
|
origin: CONNECTED_ORIGIN,
|
|
type: "network",
|
|
currentNetworkId: "mainnet",
|
|
requestedNetworkId: "sepolia",
|
|
});
|
|
|
|
// The network, the endpoints and the balances are as they were, and
|
|
// no page was told otherwise.
|
|
expect(bg.walletState()).toEqual(before);
|
|
expect(bg.chainChangedEvents()).toEqual([]);
|
|
});
|
|
|
|
test("approving the prompt switches the network", async () => {
|
|
const bg = loadBackground();
|
|
|
|
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
await bg.answerPrompt(true);
|
|
|
|
expect(request.result()).toEqual({ result: null });
|
|
const after = bg.walletState();
|
|
expect(after.networkId).toBe("sepolia");
|
|
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
|
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
|
expect(bg.chainChangedEvents()).toEqual([
|
|
{
|
|
type: "AUTISTMASK_EVENT",
|
|
eventName: "chainChanged",
|
|
data: SEPOLIA.chainId,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("rejecting the prompt changes nothing and answers 4001", async () => {
|
|
const bg = loadBackground();
|
|
const before = bg.walletState();
|
|
|
|
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
await bg.answerPrompt(false);
|
|
|
|
expect(request.result()).toEqual({ error: USER_REJECTED });
|
|
expect(bg.walletState()).toEqual(before);
|
|
expect(bg.chainChangedEvents()).toEqual([]);
|
|
});
|
|
|
|
test("closing the prompt without answering changes nothing and answers 4001", async () => {
|
|
const bg = loadBackground();
|
|
const before = bg.walletState();
|
|
|
|
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
await bg.closePrompt();
|
|
|
|
expect(request.result()).toEqual({ error: USER_REJECTED });
|
|
expect(bg.walletState()).toEqual(before);
|
|
expect(bg.chainChangedEvents()).toEqual([]);
|
|
});
|
|
|
|
test("a request for the chain already active opens no prompt", async () => {
|
|
const bg = loadBackground();
|
|
const before = bg.walletState();
|
|
|
|
const request = await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
|
|
|
|
expect(request.result()).toEqual({ result: null });
|
|
expect(bg.opened).toEqual([]);
|
|
expect(bg.walletState()).toEqual(before);
|
|
expect(bg.chainChangedEvents()).toEqual([]);
|
|
});
|
|
|
|
test("a second request while the prompt is open is refused with -32002", async () => {
|
|
const bg = loadBackground();
|
|
|
|
const first = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
const second = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
|
|
expect(second.result().error.code).toBe(-32002);
|
|
expect(bg.opened).toHaveLength(1);
|
|
|
|
// The first prompt still decides.
|
|
await bg.answerPrompt(true);
|
|
expect(first.result()).toEqual({ result: null });
|
|
expect(bg.walletState().networkId).toBe("sepolia");
|
|
});
|
|
|
|
test("a request for an unsupported chain still gets 4902 and no prompt", async () => {
|
|
const bg = loadBackground();
|
|
|
|
const request = await bg.switchChain("0x89", CONNECTED_ORIGIN);
|
|
|
|
expect(request.result().error.code).toBe(4902);
|
|
expect(bg.opened).toEqual([]);
|
|
expect(bg.walletState().networkId).toBe("mainnet");
|
|
});
|
|
|
|
test("an approved switch keeps the user's endpoint", async () => {
|
|
const bg = loadBackground();
|
|
|
|
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
|
|
await bg.answerPrompt(true);
|
|
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
|
|
|
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
|
|
await bg.answerPrompt(true);
|
|
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
|
});
|
|
});
|