Compare commits

..

1 Commits

Author SHA1 Message Date
8fadc4107f fix: sign the ERC-20 amount the send screen displayed (closes #305)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 23s
The wallet's own Send screen renders the amount, the balance and the symbol
from the block explorer's cached decimals, but confirmTx encoded the transfer
from decimals() read off the contract at signing time and nothing compared the
two. A token whose on-chain scale disagrees with the cached one -- an
upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
entry, a compromised Blockscout -- therefore signed an amount that was never
displayed, off by a power of ten for every decimal place of disagreement. The
reproduction on the issue approves 0.25 and signs 250,000,000,000.

The scale is now carried forward on the pending transaction, taken from the
same tokenBalances entry the screen's own numbers come from, and the contract's
decimals() is read at signing time only to be compared with it. A disagreement
is a refusal that names both numbers, never a preference for either: both
candidate transfers move an amount nobody approved. New
src/shared/transferAmount.js holds that check, as the confirmTx counterpart to
approvalVerify.js, and takes the same stance on an absent or unusable value --
a quantity that cannot be compared with what was displayed has not been
checked. The gas estimate encodes from the same carried value and no longer
reads decimals() at all, so the estimate is for the transfer that would be
signed.

Nothing in the e2e suite had ever clicked #btn-confirm-send, so the popup's own
Send -> ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, which is
how this shipped. It is now driven end to end to a broadcast, with the
transfer() amount hand-decoded out of the raw signed bytes and asserted against
the amount read off the confirmation screen, plus a case where the fixture's
decimals() starts answering 18 after the screen was built and nothing reaches
eth_sendRawTransaction. The fixture gains that override and a receipt, so the
wait screen resolves to the success view instead of polling for the rest of the
run.
2026-08-20 10:14:55 +00:00
9 changed files with 10 additions and 963 deletions

37
TODO.md
View File

@@ -44,43 +44,6 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-20: A page asking which chain the wallet is on is told the chain the
user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
`eth_chainId` and `net_version` answered from `currentNetwork()`, which reads
the module-level `state` singleton that nothing populates at module scope, so
a service worker revived by the page's own message answered out of
`DEFAULT_STATE` and reported mainnet `0x1`/`1` to a user on Sepolia — a dApp
building its interaction for the wrong chain. Both now `await loadState()`
first, under one load covering the pair. The read side of the background was
audited with it: the remaining singleton reads are the chain switch, the
transaction verification path and `backgroundRefresh`, which each already
load, and everything else answers from storage per call through `getState()`.
One stale read is left named but unfixed, outside this issue's scope:
`handleSendTransaction` builds its provider with no network name, so
`getProvider()` falls back to the same unloaded singleton for ethers' static
network hint.
- 2026-08-20: A web page can no longer switch the wallet's chain, and switching
no longer destroys the user's endpoints
([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)).
`wallet_switchEthereumChain` was answered for any origin at all, with no
connection check and no prompt: any page could clear the `[TESTNET]` banner
under a user 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 itself 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`, snapshotted from the network being left and restored
for the network being entered; `state.rpcUrl` stays the live value for the
active network, so no reader changed. A profile written before the map existed
has its stored pair adopted for the network it was stored under, and loses
nothing. The handler now loads state before it switches
([#316](https://git.eeqj.de/sneak/AutistMask/issues/316)): the service worker
populates nothing at module scope, so a worker revived by the page's own
message held `DEFAULT_STATE`, and the switch persisted every field of it —
wiping every wallet, every site approval and every tracked token from storage
along with the endpoint.
- 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed
([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation
screen renders from the block explorer's cached decimals; the transfer was

View File

@@ -663,52 +663,15 @@ async function handleRpc(method, params, origin) {
return { result: [] };
}
// Both answer from currentNetwork(), which reads the module-level state
// singleton, and nothing populates that at module scope. A worker revived
// by the page's own message therefore held DEFAULT_STATE and told a page
// it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317). One load covers both:
// they are the same read of the same value, and the switch handler below
// and the transaction path have the same await for the same reason.
if (method === "eth_chainId" || method === "net_version") {
await loadState();
return {
result:
method === "eth_chainId"
? currentNetwork().chainId
: currentNetwork().networkVersion,
};
if (method === "eth_chainId") {
return { result: currentNetwork().chainId };
}
if (method === "net_version") {
return { result: currentNetwork().networkVersion };
}
if (method === "wallet_switchEthereumChain") {
// Gated exactly like the signing methods, and gated before the
// same-chain early return. Switching the chain is wallet-wide: it
// moves the network the popup shows and the endpoints every other
// tab is served from, so a page the user never connected to must
// not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState();
const activeAddress = await getActiveAddress();
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
// onChainSwitch() mutates the module-level state singleton and then
// saves every field of it, and currentNetwork() reads the same
// singleton. This worker may have been started by this very message:
// nothing loads state at module scope, so without this the singleton
// is DEFAULT_STATE, the same-chain check compares against the wrong
// network, and the save writes empty wallets, empty allowedSites and
// the default endpoints over the user's stored profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent
// as the transaction path below.
await loadState();
const chainId = params?.[0]?.chainId;
if (chainId === currentNetwork().chainId) {
return { result: null };

View File

@@ -19,26 +19,9 @@ async function onChainSwitch(newNetworkId) {
const net = networkById(newNetworkId);
// --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// state.rpcUrl / state.blockscoutUrl stay the live endpoints of the
// active network, so nothing that reads them changes. The invariant is
// that for the ACTIVE network those two fields are authoritative and
// the map entry may be stale (Settings writes the fields directly);
// for every other network the map is authoritative. Snapshotting the
// outgoing network here, before the switch, is what reconciles them.
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
const remembered = state.networkEndpoints[net.id] || {};
state.networkId = net.id;
state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
state.rpcUrl = net.defaultRpcUrl;
state.blockscoutUrl = net.defaultBlockscoutUrl;
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,

View File

@@ -14,11 +14,6 @@ const DEFAULT_STATE = {
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See onChainSwitch().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
@@ -39,9 +34,6 @@ const DEFAULT_STATE = {
const state = {
...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
currentView: null,
selectedWallet: null,
selectedAddress: null,
@@ -96,7 +88,6 @@ async function saveState() {
networkId: state.networkId,
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
networkEndpoints: state.networkEndpoints,
lastBalanceRefresh: state.lastBalanceRefresh,
activeAddress: state.activeAddress,
allowedSites: state.allowedSites,
@@ -137,30 +128,6 @@ async function loadState() {
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the
// code below and onChainSwitch() index and ASSIGN INTO this value,
// and assigning a property to a string or a number is a silent no-op
// in sloppy mode. A stored primitive would therefore be re-persisted
// unchanged forever, and every switch would fall back to the network
// default — the endpoint loss this map exists to prevent, with no
// self-healing. The allowedSites/deniedSites guards below are only
// read from, which is why they can be looser.
state.networkEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
// A profile written before this map existed carries exactly one pair
// of endpoints, belonging to whatever network it was last on. Adopt
// it as that network's remembered pair, so a custom endpoint set on
// the old build is not lost by the first switch away and back.
if (!state.networkEndpoints[state.networkId]) {
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
}
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
state.activeAddress = saved.activeAddress || null;
state.allowedSites =

View File

@@ -1,232 +0,0 @@
// 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);
});
});

View File

@@ -1,192 +0,0 @@
// What eth_chainId and net_version answer on a worker that has not loaded
// state yet.
//
// The MV3 service worker is terminated when idle and revived by the next
// message, and nothing loads state at module scope. Both methods answer from
// currentNetwork(), which reads the module-level `state` singleton, so a
// worker revived by the page's own message answered out of DEFAULT_STATE and
// told a page it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
//
// This file therefore uses the REAL state module and never calls loadState()
// itself: the handler has to do it. Same shape as
// tests/coldWorkerChainSwitch.test.js, which covers the write side.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const UNKNOWN_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
function storedProfile(networkId) {
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId,
rpcUrl: networkById(networkId).defaultRpcUrl,
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real state module behind it, over a
// storage stub that keeps what is written — so the test can also show that
// answering a read method persists nothing.
function loadColdWorker(networkId) {
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(),
}));
const store = { autistmask: storedProfile(networkId) };
let messageListener = null;
const set = jest.fn(async (items) => {
store.autistmask = items.autistmask;
});
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })),
set,
},
},
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) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function rpc(method, origin) {
let result = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: [] },
{ origin: origin || CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return { rpc, persisted: () => store.autistmask, storageSet: set };
}
describe("chain identity read by a worker that never loaded state", () => {
test("eth_chainId answers the stored chain, not the default", async () => {
// The first message this worker ever sees. Reading the unloaded
// singleton answers mainnet's 0x1 to a user who is on Sepolia.
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("eth_chainId")).toEqual({
result: SEPOLIA.chainId,
});
});
test("net_version answers the stored chain, not the default", async () => {
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("net_version")).toEqual({
result: SEPOLIA.networkVersion,
});
});
test("answers the stored chain to an origin that never connected", async () => {
// Neither method is gated on a connection, so the stale answer reached
// any page at all; the fixed answer has to as well.
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("eth_chainId", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.chainId,
});
expect(await bg.rpc("net_version", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.networkVersion,
});
});
test("answers mainnet for a profile stored on mainnet", async () => {
// The default and the stored value agree here, so this case cannot
// catch the defect; it is what keeps the fix from being a swap.
const bg = loadColdWorker("mainnet");
expect(await bg.rpc("eth_chainId")).toEqual({
result: MAINNET.chainId,
});
expect(await bg.rpc("net_version")).toEqual({
result: MAINNET.networkVersion,
});
});
test("persists nothing: these are reads", async () => {
// The load must not turn a read into a write. saveState() persists
// every field of the singleton, and a read path that reached it would
// be the wipe https://git.eeqj.de/sneak/AutistMask/issues/316 fixed.
const bg = loadColdWorker("sepolia");
await bg.rpc("eth_chainId");
await bg.rpc("net_version");
expect(bg.storageSet).not.toHaveBeenCalled();
expect(bg.persisted()).toEqual(storedProfile("sepolia"));
});
});

View File

@@ -1,212 +0,0 @@
// What a chain switch does to a worker that has not loaded state yet.
//
// The MV3 service worker is terminated when idle and revived by the next
// message, and nothing loads state at module scope. The chain-switch handler
// reaches onChainSwitch(), which mutates the module-level `state` singleton
// and then persists EVERY field of it, so a handler that runs before a load
// writes DEFAULT_STATE over the user's stored profile — every wallet, every
// site approval, every tracked token and the custom endpoint
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The same singleton is
// what currentNetwork() answers from, so the same-chain early return also
// compares against the wrong network.
//
// This file therefore uses the REAL state module and never calls loadState()
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
// state module wholesale and tests/networkEndpoints.test.js always loads
// first, so neither can see this.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, and a wallet whose loss is the whole point.
const CUSTOM_RPC = "http://127.0.0.1:8545";
const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// A profile as an installed extension holds it, on `networkId`.
function storedProfile(networkId) {
return {
hasWallet: true,
wallets: walletFixture(),
activeAddress: ADDRESS,
networkId,
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
theme: "dark",
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real state and chain-switch modules
// behind it, over a storage stub that actually keeps what is written — a
// wipe is only observable against storage that remembers.
function loadColdWorker(networkId) {
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(),
}));
const store = { autistmask: storedProfile(networkId) };
let messageListener = null;
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })),
set: jest.fn(async (items) => {
store.autistmask = items.autistmask;
}),
},
},
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) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "wallet_switchEthereumChain",
params: [{ chainId }],
},
{ origin: CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
switchChain,
persisted: () => store.autistmask,
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
describe("a chain switch on a worker that never loaded state", () => {
test("keeps the wallets, approvals, tokens and custom endpoint", async () => {
const bg = loadColdWorker("mainnet");
const result = await bg.switchChain(SEPOLIA.chainId);
expect(result).toEqual({ result: null });
const after = bg.persisted();
// The switch itself happened.
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
// And it took nothing else with it. Without the load these come back
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
// extension gone, encrypted secrets included.
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
expect(after.theme).toBe("dark");
// The user's mainnet endpoint is remembered rather than replaced by
// the public default, so switching back returns it.
expect(after.networkEndpoints.mainnet).toEqual({
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await bg.switchChain(MAINNET.chainId);
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
});
test("compares the requested chain against the stored one, not the default", async () => {
// Stored on Sepolia, asked for mainnet. Reading the unloaded
// singleton makes this look like the chain already active, so the
// page is told the switch succeeded while the wallet stays on the
// testnet it was on.
const bg = loadColdWorker("sepolia");
const result = await bg.switchChain(MAINNET.chainId);
expect(result).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: MAINNET.chainId,
},
]);
});
});

View File

@@ -1193,9 +1193,8 @@ test("the theme and network selectors carry a non-default persisted value (#229)
// and a selector stuck on `dark`/`sepolia` would otherwise be
// indistinguishable here from one that persists correctly. Switching
// the network back also returns state.rpcUrl and state.blockscoutUrl
// to the mainnet endpoints onChainSwitch() remembered, which this
// fixture never customised and so are the mainnet defaults
// src/shared/state.js starts with.
// to the mainnet defaults that onChainSwitch() overwrote, which are
// the values src/shared/state.js starts with.
await env.page.selectOption("#settings-theme", "system");
await env.page.selectOption("#settings-network", "mainnet");

View File

@@ -1,192 +0,0 @@
// 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);
});
});