Files
AutistMask/tests/approvalTx.test.js
T
sneak dfd197b93b
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 0s
check / check (push) Successful in 1m4s
harden: bound the total network fee by gasLimit × fee, on both send paths (closes #399)
The two per-field ceilings in approvalVerify.js were checked independently,
but the fee a validator is paid is gasLimit × fee per gas: a gas limit and a
fee each under their own ceiling still multiply to thousands of ETH, which a
gas-consuming contract really collects. assertWithinCeilings now also bounds
that product against MAX_TOTAL_FEE (1 ETH), so both callers — populating the
dApp transaction and verifying the signed artifact — refuse it with a full
sentence naming the fee and the limit.

The wallet's own send in confirmTx.js pinned no fee fields, so ethers filled
them from the node with no bound; it now populates the transaction and runs the
same check before signing, showing the same error in the confirmation screen's
reserved errors box so nothing on screen moves.

Model: opus-4-8
2026-09-21 19:11:40 +00:00

328 lines
12 KiB
JavaScript

// Preparation of the transaction the approval screen displays.
//
// This is the half of the fix that makes the verification in
// approvalVerify.test.js mean anything: the numbers the user reads have to be
// produced before the screen is drawn and be the numbers that get signed. What
// is asserted here is that the object leaving this module is complete (nothing
// is left for the popup to fill in), that it survives the messaging boundary
// (extension messaging is JSON, which has no bigint), and that nothing the
// requesting page or the RPC node can say turns it into an approval that
// should never have been raised.
const { Network, Wallet } = require("ethers");
const {
prepareApprovalTx,
serializeApprovedTx,
POPULATE_TIMEOUT_MS,
} = require("../src/shared/approvalTx");
const {
SERIALIZED_FIELDS,
MAX_FEE_PER_GAS,
MAX_GAS_LIMIT,
} = require("../src/shared/approvalVerify");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The ordinary dApp request: recipient, value, call data, and nothing else.
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0xdeadbeef",
};
function providerWith(overrides) {
return {
getNetwork: async () => Network.from(1),
getTransactionCount: async () => 7,
estimateGas: async () => 21000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
...(overrides || {}),
};
}
// A node that only quotes a flat gas price, so populateTransaction produces a
// legacy transaction rather than an EIP-1559 one.
const legacyProvider = providerWith({
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
}),
});
describe("prepareApprovalTx", () => {
test("fills in everything the request left out", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(approved).toEqual({
type: 2,
from: signer.address,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: TX_PARAMS.value,
data: TX_PARAMS.data,
accessList: [],
});
});
// The object is displayed, signed and verified against on the far side of
// chrome.runtime.sendMessage, which is JSON: a bigint would throw on the
// way out and a field that did not survive the trip would be a field the
// user was shown and nothing compared.
test("survives the messaging boundary unchanged", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(JSON.parse(JSON.stringify(approved))).toEqual(approved);
for (const value of Object.values(approved)) {
expect(typeof value).not.toBe("bigint");
}
});
test("carries exactly the fields its type serializes, and the signer", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(Object.keys(approved).sort()).toEqual(
["type", "from", ...SERIALIZED_FIELDS[2]].sort(),
);
});
test("produces a legacy transaction when that is all the node quotes", async () => {
const approved = await prepareApprovalTx(
legacyProvider,
signer.address,
TX_PARAMS,
);
expect(approved.type).toBe(0);
expect(approved.gasPrice).toBe("0x77359400");
expect(approved.maxFeePerGas).toBeUndefined();
expect(Object.keys(approved).sort()).toEqual(
["type", "from", ...SERIALIZED_FIELDS[0]].sort(),
);
});
test("keeps a nonce, gas limit and fee the request did fix", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
nonce: "0x2",
gasLimit: "0x30d40",
maxFeePerGas: "0x12a05f200",
maxPriorityFeePerGas: "0x3b9aca00",
},
);
expect(approved.nonce).toBe("0x2");
expect(approved.gasLimit).toBe("0x30d40");
expect(approved.maxFeePerGas).toBe("0x12a05f200");
});
test("carries an access list the request asked for", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
accessList: [{ address: RECIPIENT, storageKeys: [] }],
},
);
expect(approved.accessList).toEqual([
{ address: RECIPIENT, storageKeys: [] },
]);
});
// The request is page-controlled. Anything this wallet does not act on is
// dropped before ethers sees it, so a field a future ethers learns to
// carry cannot be picked up out of it without this module knowing.
test("drops request fields this wallet does not act on", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
authorizationList: [{ address: RECIPIENT }],
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
customData: { anything: true },
},
);
expect(approved.authorizationList).toBeUndefined();
expect(approved.blobVersionedHashes).toBeUndefined();
expect(approved.customData).toBeUndefined();
expect(approved.type).toBe(2);
});
test("refuses a transaction type this wallet does not sign", async () => {
await expect(
prepareApprovalTx(providerWith(), signer.address, {
...TX_PARAMS,
type: 4,
}),
).rejects.toThrow(/type this wallet does not sign/);
});
test("refuses to raise an approval with no active address", async () => {
await expect(
prepareApprovalTx(providerWith(), null, TX_PARAMS),
).rejects.toThrow(/no active address/);
});
// The ceilings as a backstop: equality with the screen cannot bound what
// the node talks the wallet into putting on the screen, so it is refused
// before the user is shown anything.
test("refuses a fee the node quoted above the ceiling", async () => {
const gouging = providerWith({
getFeeData: async () => ({
gasPrice: MAX_FEE_PER_GAS + 1n,
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
maxPriorityFeePerGas: 1000000000n,
}),
});
await expect(
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
).rejects.toThrow(/fee per gas far above any plausible value/);
});
test("refuses a gas limit the node estimated above the ceiling", async () => {
const absurd = providerWith({
estimateGas: async () => MAX_GAS_LIMIT + 1n,
});
await expect(
prepareApprovalTx(absurd, signer.address, TX_PARAMS),
).rejects.toThrow(/gas limit no network this wallet supports/);
});
// The combined bound at population: a gas limit and a fee that are each
// under their own ceiling but multiply to thousands of ETH is refused
// before the approval window opens, so the user is never shown a
// balance-draining fee to click past.
test("refuses a fee whose product with the gas limit is over the bound", async () => {
const gouging = providerWith({
estimateGas: async () => 30000000n,
getFeeData: async () => ({
gasPrice: MAX_FEE_PER_GAS,
maxFeePerGas: MAX_FEE_PER_GAS,
maxPriorityFeePerGas: 1000000000n,
}),
});
await expect(
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
).rejects.toThrow(/network fee of up to/);
});
// No approval and no window: the failure goes back to the page the click
// came from, in a sentence.
test("reports a failed estimate as a full sentence", async () => {
const reverting = providerWith({
estimateGas: async () => {
throw new Error("execution reverted: ERC20: transfer amount");
},
});
let thrown;
try {
await prepareApprovalTx(reverting, signer.address, TX_PARAMS);
} catch (e) {
thrown = e;
}
expect(thrown.message).toMatch(
/^The transaction could not be prepared/,
);
expect(thrown.message).toMatch(/execution reverted/);
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
});
// Without a bound, an unreachable node leaves the page's promise pending
// with nothing on screen to explain it.
test("gives up on a node that never answers", async () => {
jest.useFakeTimers();
try {
const hanging = providerWith({
estimateGas: () => new Promise(() => {}),
});
const pending = prepareApprovalTx(
hanging,
signer.address,
TX_PARAMS,
);
const settled = expect(pending).rejects.toThrow(
/did not answer in time/,
);
await jest.advanceTimersByTimeAsync(POPULATE_TIMEOUT_MS + 1);
await settled;
} finally {
jest.useRealTimers();
}
});
});
describe("serializeApprovedTx", () => {
// Unreachable through prepareApprovalTx while the request type is checked
// first, which is what it is for: a node or an ethers upgrade that
// populates a type this wallet does not sign must not produce an approval.
test("refuses a populated transaction of a type this wallet does not sign", () => {
expect(() =>
serializeApprovedTx(
{ type: 3, to: RECIPIENT, nonce: 7 },
signer.address,
),
).toThrow(/type this wallet does not sign/);
});
test("refuses a populated transaction missing a quantity", () => {
expect(() =>
serializeApprovedTx(
{
type: 2,
chainId: 1n,
nonce: 7,
gasLimit: 21000n,
maxFeePerGas: 2000000000n,
to: RECIPIENT,
value: 0n,
data: "0x",
},
signer.address,
),
).toThrow(/did not supply a maxPriorityFeePerGas/);
});
test("keeps a contract creation's absent recipient absent", () => {
const approved = serializeApprovedTx(
{
type: 0,
chainId: 1n,
nonce: 7,
gasPrice: 2000000000n,
gasLimit: 21000n,
to: null,
value: 0n,
data: "0x600160005500",
},
signer.address,
);
expect(approved.to).toBeNull();
expect(approved.value).toBe("0x0");
expect(approved.data).toBe("0x600160005500");
});
});