Compare commits

..
Author SHA1 Message Date
sneak 8b68b3e681 fix: only the user switches the wallet's network (closes #408)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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
2026-10-07 21:33:11 +00:00
26 changed files with 60 additions and 819 deletions
+13 -30
View File
@@ -422,12 +422,11 @@ captured at `eth_sendRawTransaction` rather than against anything the extension
reported, rejecting each prompt is required to return a rejection to the page
rather than hang or resolve, a network switch request is required to leave the
stored network unchanged and send no `chainChanged` until it is approved, a
network switch in Settings is required to send the page `chainChanged` with the
new chain id, a prompt raised while another approval window has focus is
required to open a window of its own, and the password is required to be absent
from every message the approval window sends to the background — with the
message that would carry it required to be present, so that check cannot pass by
observing nothing. That last one is the standing floor under
prompt raised while another approval window has focus is required to open a
window of its own, and the password is required to be absent from every message
the approval window sends to the background — with the message that would carry
it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -1188,14 +1187,7 @@ The core hierarchy is **Wallets → Addresses**:
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
wallet list, and blocked from signing, sending and private-key export. It
is never deleted or rewritten.
- An **address** holds ETH and ERC-20 tokens. In an HD or xprv wallet each
address record also stores `index`, the derivation index it was made at: 0 for
the first address, the index the import scan found it at, or `nextIndex` when
"+" added it. An address's `index` and its position in the wallet's list
differ once the scan has passed over an unused index or an address has been
removed. Sending, site transactions, site signatures and private-key export
derive the key at `index`, and refuse it unless it is that address's own key
(`getSignerForAddress()` in `src/shared/wallet.js`).
- An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
@@ -1521,9 +1513,6 @@ view would leave a wallet one click from deletion.
change)
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
revealed (no screen change)
- "Reveal" (correct password, but the key derived is not this address's own,
see Data Model) → full-sentence error saying so, nothing revealed (no
screen change)
- "Back" → previous screen (AddressDetail)
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
drops the address the screen was showing, so it also takes the screen off
@@ -1796,12 +1785,10 @@ view would leave a wallet one click from deletion.
plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
resets the RPC and Blockscout endpoints to that network's defaults. The
network changes only here, or when the user approves a site's request on
**NetworkApproval**; either way, switching restores the RPC and Blockscout
endpoints last used on that network, or that network's defaults if it has
none, and sends `chainChanged` with the new chain id to every open tab.
Choosing the network already active changes nothing and sends nothing
**NetworkApproval**
- Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
@@ -2210,9 +2197,8 @@ view would leave a wallet one click from deletion.
- "Switch" / "Reject" buttons
- **Transitions**:
- "Switch" → closes popup; the background switches the network as
**Settings** does, restoring the RPC and Blockscout endpoints last used on
that network (or that network's defaults if it has none), sends
`chainChanged` to every open tab, and answers the site with success
**Settings** does, sends `chainChanged` to every open tab, and answers the
site with success
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
nothing changes
- Popup window closed without answering → the same as "Reject"
@@ -2705,7 +2691,7 @@ Currently supported:
### Non-Goals for 1.0
- Chains other than Ethereum mainnet and the Sepolia testnet
- Multi-chain support (Ethereum mainnet only)
- Hardware wallet support
## TODO
@@ -2739,10 +2725,7 @@ Currently supported:
## Policies
- We don't mention "the other wallet" by name in code or documentation. We're
our own thing. Written exception: the injected provider in
`src/content/inpage.js` sets the flag named after the other wallet to `true`.
It is an interface-compatibility flag many dApps check, and without it the
wallet stops working on their sites.
our own thing.
- The README is the complete authoritative technical documentation. It's ok if
it gets big.
+1 -4
View File
@@ -118,7 +118,4 @@ contradicts either, the originals govern.
- [ ] "Address" not "account" or "derived key"
- [ ] "Password" not "encryption key" or "vault passphrase"
- [ ] Error messages are full sentences
- [ ] No competitor mentioned by name in code or documentation. Written
exception: the injected provider in `src/content/inpage.js` sets the flag
named after the other wallet to `true`, an interface-compatibility flag
many dApps check (README.md, Policies)
- [ ] No competitor mentioned by name in code or documentation
+1 -31
View File
@@ -44,25 +44,6 @@ then continue tagging as milestones land.
# Completed Steps
- 2026-10-08: The browser suites no longer read a screen before the page has
shown it ([#502](https://git.eeqj.de/sneak/AutistMask/issues/502)). Their wait
for a screen used to pass as soon as the element laid out, which every view
does until the page's stylesheet has applied, so an approval test could read
the prompt's fields before the page's script had filled them. `visible()` in
`tests/e2e/harness.js` and `waitVisible()` in `tests/e2e/firefox/driver.js`
now also wait for the page to finish loading and for neither the element nor
anything around it to carry the `hidden` class that `showView()` puts on every
view but the current one. No caller changed.
- 2026-10-08: Switching the network in Settings now tells open pages
([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is
saved, Settings asks the background to send `chainChanged` with the new chain
id to every open tab, through the same function an approved site request uses.
Choosing the network already active changes nothing and sends nothing. Only
the extension's own pages can ask for this. `tests/chainSwitchGate.test.js`
drives the real Settings view against the background, and the Chrome suite
checks that the test page hears both switches.
- 2026-10-07: A site can no longer switch the wallet's network by itself
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's
`wallet_switchEthereumChain` request for the other supported network opens a
@@ -73,20 +54,9 @@ then continue tagging as milestones land.
or closing the prompt answers 4001. One such prompt per site at a time. The
approval window no longer shows the connection prompt while it waits for the
background to describe the approval, because that prompt's "Allow" answers on
the same port as the new one; `tests/approvalWindow.test.js` checks that it
shows no screen until then. `tests/chainSwitchGate.test.js` and both browser
the same port as the new one. `tests/chainSwitchGate.test.js` and both browser
suites drive the prompt.
- 2026-10-07: Two contradictions between the documents and the code are resolved
as ruled on [#165](https://git.eeqj.de/sneak/AutistMask/issues/165). The
README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in
scope and other chains out of it. The injected provider's flag named after the
other wallet stays, as an interface-compatibility flag many dApps check, and
is written down as an exception to the rule against naming competitors in the
README's Policies section, in `RULES.md` and in a comment beside it in
`src/content/inpage.js`. `script/check-censored` already allows that flag only
in that file and its built copies, so it is unchanged.
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
check that every swept field is driven both truthy and falsy counts only
-8
View File
@@ -1471,7 +1471,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
"AUTISTMASK_ADDRESSES_REMOVED",
"AUTISTMASK_GET_CONNECTED_SITES",
"AUTISTMASK_REMOVE_SITE",
"AUTISTMASK_NETWORK_CHANGED",
];
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
@@ -1855,13 +1854,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
broadcastSiteRemoved(msg.origin);
return false;
}
// Settings switched the network and has saved it. Open tabs are told the
// new chain id the same way as after a site's approved switch request.
if (msg.type === "AUTISTMASK_NETWORK_CHANGED") {
broadcastChainChanged(msg.chainId);
return false;
}
});
module.exports = { PROXY_METHODS };
+1 -4
View File
@@ -99,10 +99,7 @@
const provider = {
isAutistMask: true,
// An interface-compatibility flag many dApps check. Kept as a written
// exception to the rule that no competitor is named in code: see
// Policies in README.md, and RULES.md.
isMetaMask: true,
isMetaMask: true, // compatibility — many dApps check this
chainId: currentChainId,
networkVersion: currentNetworkVersion,
selectedAddress: null,
+2 -14
View File
@@ -184,12 +184,7 @@ async function importMnemonic(ctx) {
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
@@ -210,7 +205,6 @@ async function importMnemonic(ctx) {
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
tokenBalances: [],
}));
@@ -305,12 +299,7 @@ async function importXprvKey(ctx) {
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
@@ -331,7 +320,6 @@ async function importXprvKey(ctx) {
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
tokenBalances: [],
}));
+10 -10
View File
@@ -329,9 +329,9 @@ function showTxApproval(details) {
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: the active network can
// change, in Settings or when the user approves a site's request, after
// this transaction is prepared and change back before it is signed.
// Network line names, not the active network's: a site can switch the
// active network after this transaction is prepared and back before it is
// signed.
$("approve-tx-value").textContent =
ethValueFormatted +
" " +
@@ -863,15 +863,15 @@ function gateOnWalletDefect(errorId, buttonId, address) {
return true;
}
// Locate the wallet and the stored address record owning an address. Returns
// null when no wallet holds it. Approvals look up the address they were raised
// for, not whichever address is active now: the approval named one account,
// and signing with another is what verification refuses.
// Locate the wallet and the address index owning an address. Returns null when
// no wallet holds it. Approvals look up the address they were raised for, not
// whichever address is active now: the approval named one account, and signing
// with another is what verification refuses.
function findWalletFor(address) {
for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) {
return { wallet, addr: wallet.addresses[i] };
return { wallet, addrIndex: i };
}
}
}
@@ -998,7 +998,7 @@ function init(_ctx) {
try {
const signer = getSignerForAddress(
active.wallet,
active.addr,
active.addrIndex,
decryptedSecret,
);
// Sign the approved transaction exactly as it was displayed. The
@@ -1126,7 +1126,7 @@ function init(_ctx) {
try {
const signer = getSignerForAddress(
active.wallet,
active.addr,
active.addrIndex,
decryptedSecret,
);
const sp = pendingSignParams;
+1 -1
View File
@@ -583,7 +583,7 @@ function init(_ctx) {
try {
const signer = getSignerForAddress(
wallet,
wallet.addresses[state.selectedAddress],
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl, state.networkId);
+1 -12
View File
@@ -135,18 +135,7 @@ async function reveal() {
// decrypt ran, the wipe has already happened, so the key is not
// even derived, let alone written.
if (!isCurrentReveal(generation)) return;
let signer;
try {
signer = getSignerForAddress(
wallet,
wallet.addresses[addressIndex],
secret,
);
} catch (e) {
// The password was right; it is the key that was refused.
showError("export-privkey-password-error", e.message);
return;
}
const signer = getSignerForAddress(wallet, addressIndex, secret);
$("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey;
-1
View File
@@ -354,7 +354,6 @@ function render(ctx) {
);
wallet.addresses.push({
address: newAddr,
index: wallet.nextIndex,
balance: "0.0000",
tokenBalances: [],
});
-4
View File
@@ -316,11 +316,7 @@ function init(ctx) {
const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value;
if (newId === state.networkId) return;
const net = await onChainSwitch(newId);
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + ".");
+2 -3
View File
@@ -84,9 +84,8 @@ function show(tx) {
contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's:
// the active network can change, in Settings or when the user
// approves a site's request, before a later popup shows this
// screen again.
// a site can switch the active network before a later popup
// shows this screen again.
chainId: tx.chainId,
},
};
+2 -3
View File
@@ -89,9 +89,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it.
// on, not the active network's: a site can switch the active network
// while this screen is open or before a later popup resumes it.
const symbol =
txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
+6 -20
View File
@@ -110,21 +110,16 @@ function addressFromPrivateKey(key) {
return w.address;
}
// The signer for `addr`, one of the wallet's stored address records. An HD or
// xprv address is derived at its own `index`, never at its position in the
// wallet's list: the two differ once an import scan passes over an unused
// index, or once an address that is not the last is removed. Whatever the
// wallet type, the key is refused unless it is the key of `addr.address`.
function getSignerForAddress(walletData, addr, decryptedSecret) {
let signer;
function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
if (walletData.type === "hd") {
const node = HDNodeWallet.fromPhrase(
decryptedSecret,
"",
BIP44_ETH_PATH,
);
signer = node.deriveChild(addr.index);
} else if (walletData.type === "xprv") {
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
// Checked here rather than through masterXprvOrThrow so the message
// fits the situation: nobody is importing anything at signing time,
// and this wallet is already in storage. src/shared/walletDefects.js
@@ -142,18 +137,9 @@ function getSignerForAddress(walletData, addr, decryptedSecret) {
"is not a master key, so it cannot sign.",
);
}
signer = node.derivePath(BIP44_ETH_PATH).deriveChild(addr.index);
} else {
signer = new Wallet(decryptedSecret);
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
}
if (signer.address.toLowerCase() !== addr.address.toLowerCase()) {
throw new Error(
"The key this wallet holds for " +
addr.address +
" belongs to a different address, so it was not used.",
);
}
return signer;
return new Wallet(decryptedSecret);
}
function isValidMnemonic(mnemonic) {
-386
View File
@@ -1,386 +0,0 @@
// Sending, a site's signature request and private key export each use the key
// of the address they name: the key at the index that address was derived at,
// not the one at its position in the wallet's list. The two differ after an
// import whose scan passed over an unused index, and after an address that is
// not the last is removed (https://git.eeqj.de/sneak/AutistMask/issues/510).
//
// Driven through the real import, "+" button, address removal, confirmation
// screen, signature approval screen and export screen, with the node, the vault
// and the DOM stubbed.
"use strict";
// The addresses the stub node reports a balance for, and the signed
// transactions it was handed.
const mockNode = { funded: new Set(), broadcast: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance(address) {
return mockNode.funded.has(address) ? 1n : 0n;
}
async getTransactionCount() {
return 0;
}
async estimateGas() {
return 21000n;
}
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async getNetwork() {
return { chainId: 1n };
}
async getCode() {
return "0x";
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// No password is hashed: what the wallet stores is the phrase itself.
jest.mock("../src/shared/vault", () => ({
encryptWithPassword: async (secret) => secret,
decryptWithPassword: async (secret) => secret,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const {
HDNodeWallet,
Transaction,
getBytes,
verifyMessage,
} = require("ethers");
// Publicly known development phrase; never fund it.
const PHRASE = "test test test test test test test test test test test junk";
const PASSWORD = "correct horse battery staple";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// "Hello", as the hex a page sends.
const MESSAGE = "0x48656c6c6f";
// The address and key at derivation index `index` of PHRASE, worked out with
// ethers directly rather than through the wallet's own code.
function derived(index) {
return HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0/" + index);
}
// ------------------------------------------------------------ DOM stub
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
title: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(element) {
const event = { stopPropagation: () => {} };
return Promise.all((element.listeners.click || []).map((fn) => fn(event)));
}
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// ------------------------------------------------------------ harness
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { removeAddressFromState } = require("../src/shared/walletDelete");
const addWallet = require("../src/popup/views/addWallet");
const home = require("../src/popup/views/home");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const exportPrivkey = require("../src/popup/views/exportPrivkey");
const txStatus = require("../src/popup/views/txStatus");
// What the background answers when the approval window asks which approval it
// was opened for, and every message the popup sent it.
let approvalDetails;
let sent;
// The "+" button of the wallet list's last render. Each render attaches the
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
beforeEach(() => {
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
globalThis.document = makeDocument();
globalThis.window = { close: () => {} };
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
reply(
msg.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: null,
);
},
},
};
node("wallet-list").querySelectorAll = (selector) => {
if (selector !== ".btn-add-address") return [];
plusButton = makeElement("btn-add-address");
plusButton.dataset.wallet = "0";
return [plusButton];
};
// The explorer and price lookups these screens start fail offline, and
// say so; that is not under test.
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation(() => {});
}
txStatus.showError.mockClear();
state.wallets = [];
state.rpcUrl = "https://rpc.example.invalid";
state.networkId = "mainnet";
state.viewStack = [];
state.currentView = "main";
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
confirmTx.init({});
approval.init({});
exportPrivkey.init();
});
afterEach(() => {
jest.restoreAllMocks();
});
// Import PHRASE on the add-wallet screen, with the stub node reporting a
// balance at each of `fundedIndices`, and wait for the address scan.
async function importPhrase(fundedIndices) {
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
await click(node("tab-mnemonic"));
node("wallet-mnemonic").value = PHRASE;
node("add-wallet-password").value = PASSWORD;
node("add-wallet-password-confirm").value = PASSWORD;
await click(node("btn-add-wallet-confirm"));
}
// Press the wallet's "+" button on Home.
async function addAddress() {
home.render({
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
await click(plusButton);
}
// The key the export screen reveals for the address at `position`.
async function exportedKey(position) {
exportPrivkey.show(0, position);
node("export-privkey-password").value = PASSWORD;
await click(node("btn-export-privkey-confirm"));
return node("export-privkey-value").textContent;
}
// Send from the address at `position`; resolves to the signed transaction the
// node was handed, or undefined when none was.
async function sendFrom(position) {
state.selectedWallet = 0;
state.selectedAddress = position;
confirmTx.show({
from: state.wallets[0].addresses[position].address,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
await click(node("btn-confirm-send"));
return mockNode.broadcast.pop();
}
// A site asks the address at `position` to sign MESSAGE; resolves to the
// response the approval window sent the background.
async function signFor(position) {
const address = state.wallets[0].addresses[position].address;
approvalDetails = {
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
signParams: {
method: "personal_sign",
message: MESSAGE,
from: address,
},
};
await approval.show(1);
node("approve-sign-password").value = PASSWORD;
await click(node("btn-approve-sign"));
return sent.find((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE");
}
describe.each([
[
"an import whose scan passed over index 3",
async () => {
await importPhrase([0, 1, 2, 4]);
},
// Address 4 on Home, derived at index 4.
{ position: 3, index: 4 },
],
[
"removing the middle one of three addresses",
async () => {
await importPhrase([0]);
await addAddress();
await addAddress();
removeAddressFromState(state, 0, 1);
},
// Address 2 on Home after the removal, derived at index 2.
{ position: 1, index: 2 },
],
])("after %s", (_name, setUp, { position, index }) => {
beforeEach(setUp);
test("the wallet lists the address derived at that index there", () => {
expect(state.wallets[0].addresses[position].address).toBe(
derived(index).address,
);
});
test("export reveals that address's key", async () => {
expect(await exportedKey(position)).toBe(derived(index).privateKey);
});
test("a send is signed by that address", async () => {
const signed = await sendFrom(position);
expect(Transaction.from(signed).from).toBe(derived(index).address);
});
test("a site's signature is made by that address", async () => {
const response = await signFor(position);
expect(verifyMessage(getBytes(MESSAGE), response.signature)).toBe(
derived(index).address,
);
});
});
describe("an address whose stored index derives a different address", () => {
function refusal() {
return (
"The key this wallet holds for " +
derived(0).address +
" belongs to a different address, so it was not used."
);
}
beforeEach(async () => {
await importPhrase([0]);
state.wallets[0].addresses[0].index = 1;
});
test("export reveals nothing and says why", async () => {
expect(await exportedKey(0)).toBe("");
expect(node("export-privkey-password-error").textContent).toBe(
refusal(),
);
});
test("nothing is sent", async () => {
expect(await sendFrom(0)).toBeUndefined();
expect(txStatus.showError).toHaveBeenCalledWith(
expect.anything(),
null,
refusal(),
);
});
test("nothing is signed", async () => {
const response = await signFor(0);
expect(response.signature).toBeUndefined();
expect(response.error).toBe(refusal());
});
});
+1 -3
View File
@@ -136,9 +136,7 @@ async function openTxApproval(approvedTx) {
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
},
];
approval.init({});
+2 -10
View File
@@ -1455,11 +1455,7 @@ describe("background population to popup signing to verification", () => {
// What the popup does with it: signs it as given, populating nothing.
async function popupSigns(approvedTx) {
const localSigner = getSignerForAddress(
walletData,
{ address: signer.address },
SIGNER_KEY,
);
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
return localSigner.signTransaction({ ...approvedTx });
}
@@ -1554,11 +1550,7 @@ describe("background population to popup signing to verification", () => {
// address the user has switched to.
test("the approved from stops the popup signing with another key", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS);
const otherSigner = getSignerForAddress(
walletData,
{ address: other.address },
OTHER_KEY,
);
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
await expect(
otherSigner.signTransaction({ ...approvedTx }),
).rejects.toThrow(/from address mismatch/);
-47
View File
@@ -1,47 +0,0 @@
// The approval window shows no screen until the background has described the
// approval it answers (https://git.eeqj.de/sneak/AutistMask/issues/408). The
// connection prompt's "Allow" and the network switch prompt's "Switch" answer
// on the same port, so a window that showed the connection prompt while it
// waited could approve a network switch.
//
// Booted through the real popup entry point, which is where the connection
// prompt used to be put up before the background had answered.
const {
bootPopup,
cleanupPopup,
settle,
unversionedValidProfile,
} = require("./support/popupBoot");
afterEach(cleanupPopup);
test("the approval window shows no screen until the background describes the approval", async () => {
// The background's answer, held until the test gives it.
let answer = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
},
},
});
expect(answer).not.toBeNull();
// No screen at all, the connection prompt included.
expect(env.visibleViews()).toEqual([]);
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]);
});
+1 -117
View File
@@ -16,9 +16,7 @@
//
// 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. The last block
// covers what the background tells open tabs when the user switches the
// network in Settings.
// goes through storage rather than the shared state singleton.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
@@ -227,14 +225,6 @@ function loadBackground() {
answerPrompt,
closePrompt,
opened,
// A message to the background from `sender`, and its answer.
send: (msg, sender) => {
let reply = null;
messageListener(msg, sender, (r) => {
reply = r;
});
return reply;
},
walletState: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
@@ -400,109 +390,3 @@ describe("only the user switches the network", () => {
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
});
});
// Switching the network in Settings tells open pages, as an approved site
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive
// the real Settings view over the background's storage, with what it sends
// delivered to the background from the extension's own page.
describe("switching the network in Settings tells every open tab", () => {
const POPUP = { url: EXT_URL + "src/popup/index.html" };
// A stand-in for one DOM node: enough of an element for init() to set
// properties on it and hang listeners off it.
function fakeElement() {
return {
value: "",
checked: false,
textContent: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
// Settings, opened the way the popup opens it. Returns its network
// selector.
async function openSettings(bg) {
const elements = {};
const element = (id) => (elements[id] ||= fakeElement());
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
updateDebugBanner: () => {},
showFlash: () => {},
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
await require("../src/shared/state").loadState();
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
const select = element("settings-network");
select.value = "mainnet";
return select;
}
// The user picks `networkId` in the selector.
async function choose(select, networkId) {
select.value = networkId;
await select.listeners.change();
await settle();
}
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
});
test("switching to the other network sends chainChanged once, with its chain id", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
await choose(select, "sepolia");
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("choosing the network already active changes nothing and sends nothing", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
const before = bg.walletState();
await choose(select, "mainnet");
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a page cannot make the background send chainChanged", async () => {
const bg = loadBackground();
const reply = bg.send(
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
{ url: CONNECTED_ORIGIN + "/" },
);
await settle();
expect(reply).toEqual({ error: "Unauthorized sender" });
expect(bg.chainChangedEvents()).toEqual([]);
});
});
+2 -8
View File
@@ -279,18 +279,12 @@ class Driver {
// Shown means shown: in the popup a view is switched by toggling a
// "hidden" class, and an element that is present but collapsed is not
// the thing a test means by visible. Until the page's stylesheet has
// applied that class hides nothing and every view lays out, so the page
// must also have finished loading, which it does only after its
// stylesheet, and neither the element nor anything enclosing it may
// carry the class (https://git.eeqj.de/sneak/AutistMask/issues/502).
// the thing a test means by visible.
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
return this.waitFor(
"selector " + selector + " to be visible",
`const el = document.querySelector(arguments[0]);
if (document.readyState !== "complete" || !el) return false;
if (el.closest(".hidden")) return false;
if (getComputedStyle(el).visibility !== "visible") return false;
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;`,
[selector],
+1 -26
View File
@@ -333,33 +333,8 @@ async function launch(routeOpts) {
const PASSWORD = "e2e-harness-password";
// Waits until the page shows `selector`, not only until it lays out. Until the
// page's stylesheet has applied, the `hidden` class that showView() keeps on
// every view but the current one hides nothing and every view lays out, so a
// test could read a screen before the page's script had filled it
// (https://git.eeqj.de/sneak/AutistMask/issues/502). So the page must also
// have finished loading, which it does only after its stylesheet, and neither
// the element nor anything enclosing it may carry `hidden`. Polled on a timer:
// animation frames are not guaranteed to a window that is not in front.
async function visible(page, selector, timeout = 15000) {
await page
.waitForFunction(
(sel) => {
const el = document.querySelector(sel);
if (document.readyState !== "complete" || !el) return false;
if (el.closest(".hidden")) return false;
if (getComputedStyle(el).visibility !== "visible") return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
},
selector,
{ polling: 50, timeout },
)
.catch((e) => {
throw new Error(
"the page did not show " + selector + ": " + e.message,
);
});
await page.waitForSelector(selector, { state: "visible", timeout });
}
// An empty WebAssembly module: magic number and version header, no
-36
View File
@@ -4538,42 +4538,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
);
});
// Switching the network in Settings tells the page too, as an approved site
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). The wallet
// goes back to mainnet the same way at the end.
test("a network switch in Settings tells the page (#500)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
const before = await storedNetwork(env.page);
assert(
before.networkId === "mainnet",
"this test starts on mainnet, not on " + before.networkId,
);
const eventsBefore = (await chainChangedEvents(env.dapp)).length;
await openSettings(env.page);
await env.page.selectOption("#settings-network", "sepolia");
let events = await chainChangedEvents(env.dapp, eventsBefore + 1);
assert(
events.length === eventsBefore + 1 &&
events[events.length - 1].data === sepolia.chainId,
"the page was not told of the switch to Sepolia: " +
JSON.stringify(events),
);
await env.page.selectOption("#settings-network", "mainnet");
events = await chainChangedEvents(env.dapp, eventsBefore + 2);
assert(
events.length === eventsBefore + 2 &&
events[events.length - 1].data === mainnet.chainId,
"the page was not told of the switch back to mainnet: " +
JSON.stringify(events),
);
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
});
// The closing pass over both boundaries at once. Every message the section
// put on either channel is re-read here and required to be free of the
// password — and required to be there at all, method by method, so the
+4 -4
View File
@@ -345,10 +345,10 @@ describe.each([
});
// A transaction's value and fee are in the native currency of the network the
// transaction is on, which need not be the active one. The active network can
// change, in Settings or when the user approves a site's request, after a
// transaction is prepared and change back before it is signed, and a popup
// opened after a switch shows a sent or listed transaction again. The wallet's balances follow the active network; these do not.
// transaction is on, which need not be the active one. A site can switch the
// active network after its transaction is prepared and back before it is
// signed, and a popup opened after a switch shows a sent or listed transaction
// again. The wallet's balances follow the active network; these do not.
describe.each([
["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"],
+1 -6
View File
@@ -238,10 +238,6 @@ async function settle() {
* @param {object} [options]
* @param {object} [options.storage] a storage stub from makeStorageStub(), for
* a test that needs to make writes fail or to watch the round trips.
* @param {string} [options.search] the page URL's query string, such as
* "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the
* stub's own, for a test that has to answer the background's messages.
* @returns {Promise<object>} handles onto the booted page.
*/
async function bootPopup(stored, options) {
@@ -285,13 +281,12 @@ async function bootPopup(stored, options) {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: { addListener: () => {} },
...(options && options.runtime),
},
};
globalThis.document = document;
globalThis.window = {
location: {
search: (options && options.search) || "",
search: "",
href: "chrome-extension://autistmask/src/popup/index.html",
reload: () => reloads.push(Date.now()),
},
+5 -28
View File
@@ -416,7 +416,7 @@ describe("extended key depth", () => {
expect(() =>
wallet.getSignerForAddress(
{ type: "xprv" },
{ address: VECTOR_ADDRESSES[0], index: 0 },
0,
accountXprv(VECTOR_PHRASE),
),
).toThrow(/master/i);
@@ -504,7 +504,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
{ address: VECTOR_ADDRESSES[index], index },
index,
VECTOR_PHRASE,
);
@@ -515,7 +515,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
{ address: VECTOR_ADDRESSES[index], index },
index,
masterXprv(VECTOR_PHRASE),
);
@@ -527,7 +527,7 @@ describe("getSignerForAddress", () => {
for (const index of [0, 1, 2]) {
const signer = wallet.getSignerForAddress(
{ type: "privkey" },
{ address: VECTOR_ADDRESSES[1], index },
index,
VECTOR_PRIVATE_KEYS[1],
);
@@ -535,33 +535,10 @@ describe("getSignerForAddress", () => {
}
});
// https://git.eeqj.de/sneak/AutistMask/issues/510: an address whose stored
// index does not derive it, or a private key that is not its key.
test.each([
["hd", { address: VECTOR_ADDRESSES[2], index: 1 }, VECTOR_PHRASE],
[
"xprv",
{ address: VECTOR_ADDRESSES[2], index: 1 },
masterXprv(VECTOR_PHRASE),
],
["privkey", { address: VECTOR_ADDRESSES[2] }, VECTOR_PRIVATE_KEYS[1]],
])(
"%s wallet refuses a key that is not the address's own",
(type, addr, secret) => {
expect(() =>
wallet.getSignerForAddress({ type }, addr, secret),
).toThrow(
"The key this wallet holds for " +
VECTOR_ADDRESSES[2] +
" belongs to a different address, so it was not used.",
);
},
);
test("the returned signer signs recoverably as the expected address", async () => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
{ address: VECTOR_ADDRESSES[1], index: 1 },
1,
VECTOR_PHRASE,
);
const message = "AutistMask derivation test";
+3 -3
View File
@@ -55,7 +55,7 @@ function healthyXprvWallet(name = "Master") {
xpub,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [{ address: firstAddress, index: 0, balance: "0.0000" }],
addresses: [{ address: firstAddress, balance: "0.0000" }],
};
}
@@ -558,7 +558,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
try {
wallet.getSignerForAddress(
{ type: "xprv" },
brokenXprvWallet().addresses[0],
0,
accountXprv(VECTOR_PHRASE),
);
} catch (e) {
@@ -573,7 +573,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
test("a healthy xprv wallet signs as it always did", () => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
healthyXprvWallet().addresses[0],
0,
masterXprv(VECTOR_PHRASE),
);