Sign & Send wrote its refusals, a network changed since review and a gas limit or fee over its bound, into the errors box above the password field. That box reserves one empty line, so a refusal grew it and pushed the password field and Sign & Send down. Each refusal now has its own box with its sentence fixed in index.html, holding its space from the first paint and only made visible, as #558 did for the balance messages. One sentence is true of all three bounds and quotes no number; the full reason is logged. A Chrome test raises the stub node's fee only at signing and checks nothing moved; a unit test refuses a gas limit over its bound into the same box. Model: opus-5-5
262 lines
8.7 KiB
JavaScript
262 lines
8.7 KiB
JavaScript
// The wallet's OWN send path enforces the same combined fee bound the dApp
|
|
// path does (https://git.eeqj.de/sneak/AutistMask/issues/399).
|
|
//
|
|
// The send in src/popup/views/confirmTx.js pins no fee fields, so ethers fills
|
|
// maxFeePerGas and the gas limit from whatever the configured RPC node
|
|
// answers. Nothing bounded that: a hostile node could report a fee whose
|
|
// product with the gas limit is thousands of ETH, and it would be both
|
|
// displayed and signed. populateVerifyAndSend() populates the transaction and
|
|
// runs assertWithinCeilings() on the populated fees before signing, so an
|
|
// over-bound send is refused before anything is broadcast.
|
|
//
|
|
// The check is driven here with a fake connected signer rather than a real
|
|
// one: populateTransaction() returns the fees the node would have produced,
|
|
// and sendTransaction() records whether the send actually happened.
|
|
//
|
|
// The last test drives the real confirmation screen instead, with only the
|
|
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
|
|
// is refused in the same box as a fee over the bound
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
|
|
// nothing on the screen is checked by the Chrome e2e suite.
|
|
|
|
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
|
|
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
|
|
// it was handed.
|
|
const mockNode = {
|
|
gasLimit: 150000000n,
|
|
maxFeePerGas: 2000000000n,
|
|
broadcast: [],
|
|
};
|
|
|
|
jest.mock("ethers", () => {
|
|
const actual = jest.requireActual("ethers");
|
|
class StubProvider {
|
|
constructor(url, network) {
|
|
this.network = network;
|
|
}
|
|
async getNetwork() {
|
|
return this.network;
|
|
}
|
|
async getBalance() {
|
|
return actual.parseEther("5");
|
|
}
|
|
async getCode() {
|
|
return "0x";
|
|
}
|
|
async getTransactionCount() {
|
|
return 1;
|
|
}
|
|
async getFeeData() {
|
|
return {
|
|
maxFeePerGas: mockNode.maxFeePerGas,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
gasPrice: 1000000000n,
|
|
};
|
|
}
|
|
async estimateGas() {
|
|
return mockNode.gasLimit;
|
|
}
|
|
async broadcastTransaction(signed) {
|
|
mockNode.broadcast.push(signed);
|
|
return { hash: "0x" + "ab".repeat(32) };
|
|
}
|
|
}
|
|
return { ...actual, JsonRpcProvider: StubProvider };
|
|
});
|
|
|
|
// The screens a send that was not refused goes on to.
|
|
jest.mock("../src/popup/views/txStatus", () => ({
|
|
showWait: jest.fn(),
|
|
showError: jest.fn(),
|
|
}));
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
|
|
|
global.fetch = jest.fn(() => {
|
|
throw new Error("tests must not perform network requests");
|
|
});
|
|
|
|
// A stub DOM: every id resolves to a recording element.
|
|
const elements = new Map();
|
|
|
|
function makeEl(id) {
|
|
const handlers = new Map();
|
|
return {
|
|
id,
|
|
textContent: "",
|
|
innerHTML: "",
|
|
value: "",
|
|
disabled: false,
|
|
style: {},
|
|
dataset: {},
|
|
classList: {
|
|
add() {},
|
|
remove() {},
|
|
toggle() {},
|
|
contains: () => false,
|
|
},
|
|
handlers,
|
|
addEventListener(name, fn) {
|
|
handlers.set(name, fn);
|
|
},
|
|
querySelectorAll: () => [],
|
|
remove() {},
|
|
};
|
|
}
|
|
|
|
global.document = {
|
|
getElementById(id) {
|
|
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
return elements.get(id);
|
|
},
|
|
};
|
|
|
|
const { Wallet } = require("ethers");
|
|
const { encryptWithPassword } = require("../src/shared/vault");
|
|
const { state, loadState } = require("../src/shared/state");
|
|
const txStatus = require("../src/popup/views/txStatus");
|
|
const confirmTx = require("../src/popup/views/confirmTx");
|
|
const { populateVerifyAndSend } = confirmTx;
|
|
const {
|
|
MAX_GAS_LIMIT,
|
|
MAX_FEE_PER_GAS,
|
|
MAX_TOTAL_FEE,
|
|
} = require("../src/shared/approvalVerify");
|
|
|
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
// A signer whose populateTransaction() fills in the fees a node quoted and
|
|
// whose sendTransaction() records the call, so a test can assert whether the
|
|
// send was reached at all.
|
|
function fakeSigner(fees) {
|
|
const sent = [];
|
|
return {
|
|
sent,
|
|
populateTransaction: async (request) => ({
|
|
...request,
|
|
from: RECIPIENT,
|
|
nonce: 0,
|
|
type: 2,
|
|
chainId: 1n,
|
|
gasLimit: fees.gasLimit,
|
|
maxFeePerGas: fees.maxFeePerGas,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
}),
|
|
sendTransaction: async (tx) => {
|
|
sent.push(tx);
|
|
return { hash: "0xabc" };
|
|
},
|
|
};
|
|
}
|
|
|
|
const ETH_SEND = { token: "ETH", to: RECIPIENT, amount: "1.0" };
|
|
|
|
describe("populateVerifyAndSend enforces the combined fee bound", () => {
|
|
// A gas limit and a fee that are each under their own field ceiling, but
|
|
// multiply to about 3,000 ETH — the combination the per-field ceilings
|
|
// cannot see.
|
|
const OVER = { gasLimit: 30000000n, maxFeePerGas: MAX_FEE_PER_GAS };
|
|
|
|
test("each field is under its ceiling but the product is over the bound", () => {
|
|
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
|
|
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
|
|
MAX_TOTAL_FEE,
|
|
);
|
|
});
|
|
|
|
test("refuses an over-bound send without broadcasting it", async () => {
|
|
const signer = fakeSigner(OVER);
|
|
let thrown;
|
|
try {
|
|
await populateVerifyAndSend(signer, ETH_SEND);
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
expect(thrown).toBeDefined();
|
|
expect(thrown.approvalMismatch).toBe(true);
|
|
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
|
expect(thrown.message).toContain("3000.0 ETH");
|
|
expect(thrown.message).toContain("1.0 ETH");
|
|
// The one guarantee that matters: nothing was signed or sent.
|
|
expect(signer.sent).toHaveLength(0);
|
|
});
|
|
|
|
test("broadcasts a send whose product is just under the bound", async () => {
|
|
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
|
|
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
|
|
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(MAX_TOTAL_FEE);
|
|
const signer = fakeSigner(under);
|
|
const tx = await populateVerifyAndSend(signer, ETH_SEND);
|
|
expect(tx.hash).toBe("0xabc");
|
|
expect(signer.sent).toHaveLength(1);
|
|
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
|
|
});
|
|
});
|
|
|
|
describe("Sign & Send on the confirmation screen", () => {
|
|
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|
const PASSWORD = "correct horse battery staple";
|
|
let encryptedSecret;
|
|
|
|
beforeAll(async () => {
|
|
await loadState();
|
|
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
|
});
|
|
|
|
function el(id) {
|
|
return global.document.getElementById(id);
|
|
}
|
|
|
|
function shown(id) {
|
|
return el(id).style.visibility === "visible";
|
|
}
|
|
|
|
// show() reads the balance and estimates the fee without awaiting
|
|
// either; this lets both finish.
|
|
async function settle() {
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
|
|
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
|
|
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
|
|
MAX_TOTAL_FEE,
|
|
);
|
|
|
|
const from = new Wallet(PRIVATE_KEY).address;
|
|
state.wallets = [
|
|
{
|
|
type: "key",
|
|
name: "Wallet 1",
|
|
encryptedSecret,
|
|
addresses: [{ address: from }],
|
|
},
|
|
];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
confirmTx.init({});
|
|
confirmTx.show({
|
|
from,
|
|
to: RECIPIENT,
|
|
ensName: null,
|
|
amount: "1",
|
|
token: "ETH",
|
|
tokenSymbol: null,
|
|
tokenDecimals: null,
|
|
max: false,
|
|
});
|
|
await settle();
|
|
expect(el("btn-confirm-send").disabled).toBe(false);
|
|
expect(shown("confirm-fee-bound-error")).toBe(false);
|
|
|
|
el("confirm-tx-password").value = PASSWORD;
|
|
await el("btn-confirm-send").handlers.get("click")();
|
|
|
|
expect(shown("confirm-fee-bound-error")).toBe(true);
|
|
expect(mockNode.broadcast).toEqual([]);
|
|
expect(txStatus.showWait).not.toHaveBeenCalled();
|
|
expect(txStatus.showError).not.toHaveBeenCalled();
|
|
});
|
|
});
|