Sign & Send wrote its refusal of a From address the selected wallet does not hold onto the password error line, which reserves one line. The sentence runs to about three, so it pushed Sign & Send down. The refusal now has its own box above the password field, its sentence fixed in index.html, holding its space from the first paint and only made visible, as #583 did for the other refusals. Drawing the screen and each press hide it again. A Chrome test writes the other wallet into the stored selection, reopens the popup onto the screen, presses Sign & Send and checks that nothing was broadcast and nothing moved. Model: opus-5-5
504 lines
16 KiB
JavaScript
504 lines
16 KiB
JavaScript
// Sending, a site's transaction and signature requests 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). A send is signed by the
|
|
// From address its confirmation screen shows, whichever address is selected.
|
|
//
|
|
// Driven through the real import, "+" button, address removal, confirmation
|
|
// screen, transaction and signature approval screens 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,
|
|
Mnemonic,
|
|
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";
|
|
// PHRASE's master extended private key, as the xprv import takes it.
|
|
const XPRV = HDNodeWallet.fromSeed(
|
|
Mnemonic.fromPhrase(PHRASE).computeSeed(),
|
|
).extendedKey;
|
|
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: () => {},
|
|
startRefresh: () => {},
|
|
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"));
|
|
}
|
|
|
|
// The same, importing XPRV on the add-wallet screen's xprv tab.
|
|
async function importXprv(fundedIndices) {
|
|
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
|
|
await click(node("tab-xprv"));
|
|
node("import-xprv-key").value = XPRV;
|
|
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;
|
|
}
|
|
|
|
// Confirm a send whose From line shows `from`, with the address at position
|
|
// `selected` selected; resolves to the signed transaction the node was handed,
|
|
// or undefined when none was.
|
|
async function confirmSend(from, selected) {
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = selected;
|
|
confirmTx.show({
|
|
from,
|
|
to: RECIPIENT,
|
|
amount: "0.5",
|
|
token: "ETH",
|
|
});
|
|
await settle();
|
|
node("confirm-tx-password").value = PASSWORD;
|
|
await click(node("btn-confirm-send"));
|
|
return mockNode.broadcast.pop();
|
|
}
|
|
|
|
// Send from the address at `position`, with that address selected, as the Send
|
|
// button leaves it.
|
|
function sendFrom(position) {
|
|
return confirmSend(state.wallets[0].addresses[position].address, position);
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
|
|
// A site asks the address at `position` to send RECIPIENT a transaction of no
|
|
// value; resolves to the response the approval window sent the background.
|
|
async function transactFor(position) {
|
|
const address = state.wallets[0].addresses[position].address;
|
|
approvalDetails = {
|
|
type: "tx",
|
|
origin: "https://dapp.example",
|
|
isPhishingDomain: false,
|
|
approvedFrom: address,
|
|
approvedTx: {
|
|
type: 2,
|
|
from: address,
|
|
chainId: "0x1",
|
|
nonce: "0x0",
|
|
gasLimit: "0x5208",
|
|
maxPriorityFeePerGas: "0x3b9aca00",
|
|
maxFeePerGas: "0x77359400",
|
|
to: RECIPIENT,
|
|
value: "0x0",
|
|
data: "0x",
|
|
accessList: [],
|
|
},
|
|
};
|
|
await approval.show(1);
|
|
node("approve-tx-password").value = PASSWORD;
|
|
await click(node("btn-approve-tx"));
|
|
return sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
|
|
}
|
|
|
|
describe.each([
|
|
[
|
|
"a recovery phrase import whose scan found only the first address",
|
|
async () => {
|
|
await importPhrase([0]);
|
|
},
|
|
// The address the import stores before its scan.
|
|
{ position: 0, index: 0 },
|
|
],
|
|
[
|
|
"an xprv import whose scan found only the first address",
|
|
async () => {
|
|
await importXprv([0]);
|
|
},
|
|
{ position: 0, index: 0 },
|
|
],
|
|
[
|
|
"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 },
|
|
],
|
|
[
|
|
"an xprv import whose scan passed over index 3",
|
|
async () => {
|
|
await importXprv([0, 1, 2, 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,
|
|
);
|
|
});
|
|
|
|
test("a site's transaction is signed by that address", async () => {
|
|
const response = await transactFor(position);
|
|
expect(Transaction.from(response.rawSignedTx).from).toBe(
|
|
derived(index).address,
|
|
);
|
|
});
|
|
});
|
|
|
|
// The selection and the confirmation screen are saved apart, so the popup can
|
|
// reopen onto the screen with another address selected than its From line
|
|
// shows.
|
|
describe("a confirmation screen whose From is not the selected address", () => {
|
|
beforeEach(async () => {
|
|
await importPhrase([0, 1]);
|
|
});
|
|
|
|
test("the send is signed by the From address shown", async () => {
|
|
const signed = await confirmSend(derived(1).address, 0);
|
|
expect(Transaction.from(signed).from).toBe(derived(1).address);
|
|
});
|
|
|
|
// The refusal's sentence is fixed in index.html, in a box of its own that
|
|
// holds its space from the first paint, so showing it moves nothing.
|
|
test("nothing is sent when the selected wallet does not hold it", async () => {
|
|
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
|
|
expect(node("confirm-from-address-error").style.visibility).toBe(
|
|
"visible",
|
|
);
|
|
expect(node("confirm-tx-password-error").textContent).toBe("");
|
|
|
|
// Reviewing a send again draws the screen again, which hides it.
|
|
confirmTx.show({
|
|
from: derived(0).address,
|
|
to: RECIPIENT,
|
|
amount: "0.5",
|
|
token: "ETH",
|
|
});
|
|
await settle();
|
|
expect(node("confirm-from-address-error").style.visibility).toBe(
|
|
"hidden",
|
|
);
|
|
});
|
|
});
|
|
|
|
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());
|
|
});
|
|
});
|