1427 lines
54 KiB
JavaScript
1427 lines
54 KiB
JavaScript
const {
|
|
Network,
|
|
Transaction,
|
|
Wallet,
|
|
decodeRlp,
|
|
encodeRlp,
|
|
} = require("ethers");
|
|
const {
|
|
verifySignedTx,
|
|
verifySignature,
|
|
assertNoForbiddenFields,
|
|
assertNothingUnchecked,
|
|
assertCanonicalBytes,
|
|
assertWithinCeilings,
|
|
sameAddress,
|
|
failureIsRetryable,
|
|
isNonceCollision,
|
|
describeTxFailure,
|
|
describeSigningFailure,
|
|
NONCE_COLLISION_MESSAGE,
|
|
ALLOWED_TX_TYPES,
|
|
SERIALIZED_FIELDS,
|
|
FORBIDDEN_FIELDS,
|
|
APPROVED_FIELDS,
|
|
TX_STAGE_SIGN,
|
|
TX_STAGE_VERIFY,
|
|
TX_STAGE_BROADCAST,
|
|
TX_STAGE_NONCE,
|
|
MAX_GAS_LIMIT,
|
|
MAX_FEE_PER_GAS,
|
|
} = require("../src/shared/approvalVerify");
|
|
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
|
const { getSignerForAddress } = require("../src/shared/wallet");
|
|
|
|
// Fixed test keys — never used for anything but these tests.
|
|
const SIGNER_KEY =
|
|
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
|
const OTHER_KEY =
|
|
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
|
|
|
|
const signer = new Wallet(SIGNER_KEY);
|
|
const other = new Wallet(OTHER_KEY);
|
|
|
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
|
|
// The chain id of the selected network, as networks.js carries it.
|
|
const SELECTED = "0x1";
|
|
const SEPOLIA = "0xaa36a7";
|
|
|
|
// Parameters as a dApp would supply them over eth_sendTransaction. Note what
|
|
// is missing: nonce, gas limit and fees. The background fills those in before
|
|
// the approval screen is drawn, which is why the approval below and not this
|
|
// object is what every comparison runs against.
|
|
const TX_PARAMS = {
|
|
from: signer.address,
|
|
to: RECIPIENT,
|
|
value: "0x2386f26fc10000",
|
|
data: "0xdeadbeef",
|
|
gas: "0x5208",
|
|
};
|
|
|
|
// The values populateTransaction() fills in when the dApp fixed none of them.
|
|
const POPULATED = {
|
|
chainId: 1,
|
|
nonce: 7,
|
|
gasLimit: 100000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
type: 2,
|
|
};
|
|
|
|
// Build a signable transaction from a request. The background populates the
|
|
// same fields through populateTransaction(); here they are fixed so the test
|
|
// needs no provider. `overrides` stands in for what a tampered or misbuilt
|
|
// popup would put on the wire.
|
|
function txFor(params, overrides) {
|
|
return {
|
|
...POPULATED,
|
|
to: params.to,
|
|
value: params.value === undefined ? 0n : BigInt(params.value),
|
|
data: params.data || "0x",
|
|
...(overrides || {}),
|
|
};
|
|
}
|
|
|
|
// The populated transaction the approval screen displayed, which is the object
|
|
// the artifact is verified against. Built from the same fields as the signable
|
|
// transaction above, because that is the point: displayed and verified are one
|
|
// object.
|
|
function approvedFor(params, overrides) {
|
|
return {
|
|
from: signer.address,
|
|
accessList: [],
|
|
...txFor(params, overrides),
|
|
};
|
|
}
|
|
|
|
// The ordinary case: the dApp's request, populated.
|
|
const APPROVED = approvedFor(TX_PARAMS);
|
|
|
|
async function signedFor(params, withWallet, overrides) {
|
|
return (withWallet || signer).signTransaction(txFor(params, overrides));
|
|
}
|
|
|
|
// Sign the approved transaction with one field changed from what was
|
|
// populated, which is the shape of every tamper case below.
|
|
async function signedWith(overrides) {
|
|
return signedFor(TX_PARAMS, signer, overrides);
|
|
}
|
|
|
|
describe("sameAddress", () => {
|
|
test("compares checksummed and lowercase forms as equal", () => {
|
|
expect(sameAddress(RECIPIENT, RECIPIENT.toLowerCase())).toBe(true);
|
|
});
|
|
|
|
test("treats two absent addresses as equal (contract creation)", () => {
|
|
expect(sameAddress(null, undefined)).toBe(true);
|
|
expect(sameAddress("", null)).toBe(true);
|
|
});
|
|
|
|
test("treats one absent address as unequal", () => {
|
|
expect(sameAddress(RECIPIENT, null)).toBe(false);
|
|
expect(sameAddress(null, RECIPIENT)).toBe(false);
|
|
});
|
|
|
|
test("does not throw on values that are not addresses", () => {
|
|
expect(sameAddress("not-an-address", RECIPIENT)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("verifySignedTx", () => {
|
|
test("accepts the approved transaction signed by the approved address", async () => {
|
|
const raw = await signedFor(TX_PARAMS);
|
|
const parsed = verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
|
expect(parsed.from).toBe(signer.address);
|
|
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
|
});
|
|
|
|
test("accepts a contract creation with no recipient", async () => {
|
|
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
|
const raw = await signedFor(params);
|
|
expect(() =>
|
|
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("accepts an absent value as zero", async () => {
|
|
const params = { to: RECIPIENT, data: "0x" };
|
|
const raw = await signedFor(params);
|
|
expect(() =>
|
|
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("accepts call data whose case differs from the approval", async () => {
|
|
const params = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
|
const raw = await signedFor(params);
|
|
expect(() =>
|
|
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("rejects a swapped recipient", async () => {
|
|
const raw = await signedFor({
|
|
...TX_PARAMS,
|
|
to: OTHER_RECIPIENT,
|
|
});
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved recipient/);
|
|
});
|
|
|
|
test("rejects an inflated value", async () => {
|
|
const raw = await signedFor({
|
|
...TX_PARAMS,
|
|
value: "0x4563918244f40000",
|
|
});
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved value/);
|
|
});
|
|
|
|
test("rejects substituted call data", async () => {
|
|
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved call data/);
|
|
});
|
|
|
|
test("rejects a transaction signed by a different address", async () => {
|
|
const raw = await signedFor(TX_PARAMS, other);
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/different address/);
|
|
});
|
|
|
|
// The address the approval named, not whichever address is active when the
|
|
// artifact comes back: an approval raised for one account cannot be
|
|
// satisfied by a signature from another, whatever the wallet switched to
|
|
// in between.
|
|
test("rejects a signature from the address that is active now", async () => {
|
|
const raw = await signedFor(TX_PARAMS, other);
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/different address than the one that was approved/);
|
|
// The same artifact against the same approval, verified for the other
|
|
// address, is what would have happened had expectedFrom been read from
|
|
// the wallet's current state.
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, other.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("rejects an unsigned transaction", () => {
|
|
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
|
expect(() =>
|
|
verifySignedTx(unsigned, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/no valid signature/);
|
|
});
|
|
|
|
test("rejects a missing or malformed payload", () => {
|
|
expect(() =>
|
|
verifySignedTx(undefined, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/missing or malformed/);
|
|
expect(() =>
|
|
verifySignedTx("nope", APPROVED, signer.address, SELECTED),
|
|
).toThrow(/missing or malformed/);
|
|
expect(() =>
|
|
verifySignedTx("0xc0ffee", APPROVED, signer.address, SELECTED),
|
|
).toThrow(/could not be decoded/);
|
|
});
|
|
|
|
test("every rejection message is a full sentence", async () => {
|
|
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
|
try {
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
|
throw new Error("expected a rejection");
|
|
} catch (e) {
|
|
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// The defect this file's approvals now stand against: for every field the dApp
|
|
// left out, the old comparison had nothing to compare and skipped the field,
|
|
// so the fee and the nonce the user read off the screen were checked by the
|
|
// ceilings alone. A populated approval fixes all of them, and an approval that
|
|
// does not fix one is a refusal rather than a pass.
|
|
describe("verifySignedTx against what was displayed", () => {
|
|
test("a fee differing from the displayed one is refused", async () => {
|
|
// Ten times the fee the screen showed, and far below the ceiling: the
|
|
// artifact the old comparison would have accepted.
|
|
const inflated = 20000000000n;
|
|
expect(inflated).toBeLessThan(MAX_FEE_PER_GAS);
|
|
const raw = await signedWith({ maxFeePerGas: inflated });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved maximum fee per gas/);
|
|
});
|
|
|
|
test("a nonce differing from the displayed one is refused", async () => {
|
|
const raw = await signedWith({ nonce: 8 });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved nonce/);
|
|
});
|
|
|
|
test("a gas limit differing from the displayed one is refused", async () => {
|
|
const raw = await signedWith({ gasLimit: 250000n });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved gas limit/);
|
|
});
|
|
|
|
test("an approval fixing no quantity is refused, not waved through", async () => {
|
|
const raw = await signedWith({});
|
|
for (const key of [
|
|
"chainId",
|
|
"nonce",
|
|
"gasLimit",
|
|
"maxFeePerGas",
|
|
"maxPriorityFeePerGas",
|
|
]) {
|
|
const incomplete = { ...APPROVED };
|
|
delete incomplete[key];
|
|
let thrown;
|
|
try {
|
|
verifySignedTx(raw, incomplete, signer.address, SELECTED);
|
|
throw new Error("expected a rejection for " + key);
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
expect(thrown.message).toMatch(/fixes no /);
|
|
expect(thrown.approvalMismatch).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("no approved transaction at all is refused", async () => {
|
|
const raw = await signedWith({});
|
|
for (const approved of [undefined, null, "0xdeadbeef"]) {
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).toThrow(/no approved transaction/);
|
|
}
|
|
});
|
|
|
|
test("an approval fixing no transaction type is refused", async () => {
|
|
const raw = await signedWith({});
|
|
const incomplete = { ...APPROVED };
|
|
delete incomplete.type;
|
|
expect(() =>
|
|
verifySignedTx(raw, incomplete, signer.address, SELECTED),
|
|
).toThrow(/fixes no transaction type/);
|
|
});
|
|
|
|
test("an artifact of a type other than the approved one is refused", async () => {
|
|
// Same fee mechanism on both sides, so only the type differs: a type 1
|
|
// artifact against a type 2 approval.
|
|
const approved = approvedFor(TX_PARAMS, {
|
|
type: 1,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
});
|
|
const raw = await signedWith({
|
|
type: 0,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
});
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).toThrow(/approved transaction type/);
|
|
});
|
|
});
|
|
|
|
// One case per consequential field: the field alone differs from what was
|
|
// approved, and that alone must refuse the signature.
|
|
describe("verifySignedTx field comparison", () => {
|
|
test("rejects a chain id that is not the selected network", async () => {
|
|
const raw = await signedWith({ chainId: 11155111 });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/different network than the one that is selected/);
|
|
});
|
|
|
|
test("rejects a chain id that is not the approved one", async () => {
|
|
// Selected network and signed chain id agree; the approval was raised
|
|
// for a different chain, so the artifact is not what was approved.
|
|
const approved = { ...APPROVED, chainId: SEPOLIA };
|
|
const raw = await signedWith({});
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).toThrow(/different network than the one that was approved/);
|
|
});
|
|
|
|
test("refuses when the selected network is unknown", async () => {
|
|
const raw = await signedWith({});
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, undefined),
|
|
).toThrow(/selected network is unknown/);
|
|
});
|
|
|
|
test("rejects a substituted nonce", async () => {
|
|
const raw = await signedWith({ nonce: 8 });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved nonce/);
|
|
});
|
|
|
|
test("rejects a substituted gas limit", async () => {
|
|
const raw = await signedWith({ gasLimit: 250000n });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved gas limit/);
|
|
});
|
|
|
|
test("rejects a substituted maximum fee per gas", async () => {
|
|
const raw = await signedWith({ maxFeePerGas: 900000000000n });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved maximum fee per gas/);
|
|
});
|
|
|
|
test("rejects a substituted maximum priority fee per gas", async () => {
|
|
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved maximum priority fee per gas/);
|
|
});
|
|
|
|
test("rejects a substituted legacy gas price", async () => {
|
|
const legacy = {
|
|
type: 0,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
};
|
|
const approved = approvedFor(TX_PARAMS, legacy);
|
|
const raw = await signedWith({ ...legacy, gasPrice: 9000000000n });
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).toThrow(/approved gas price/);
|
|
});
|
|
|
|
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
|
|
const approved = approvedFor(TX_PARAMS, {
|
|
type: 0,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
});
|
|
const raw = await signedWith({});
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).toThrow(/approved fee mechanism/);
|
|
});
|
|
|
|
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
|
|
const raw = await signedWith({
|
|
type: 0,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
});
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/approved fee mechanism/);
|
|
});
|
|
|
|
// The ceilings are a backstop against what the RPC node can talk the
|
|
// wallet into populating and displaying, so they are checked against an
|
|
// approval that carries the absurd value too — equality alone would accept
|
|
// it, which is exactly what the ceiling is there for.
|
|
test("rejects a gas limit above anything a supported network accepts", async () => {
|
|
const overrides = { gasLimit: MAX_GAS_LIMIT + 1n };
|
|
const raw = await signedWith(overrides);
|
|
expect(() =>
|
|
verifySignedTx(
|
|
raw,
|
|
approvedFor(TX_PARAMS, overrides),
|
|
signer.address,
|
|
SELECTED,
|
|
),
|
|
).toThrow(/gas limit no network this wallet supports/);
|
|
});
|
|
|
|
test("rejects an absurd fee per gas even when it was displayed", async () => {
|
|
const overrides = {
|
|
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
|
|
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
|
|
};
|
|
const raw = await signedWith(overrides);
|
|
expect(() =>
|
|
verifySignedTx(
|
|
raw,
|
|
approvedFor(TX_PARAMS, overrides),
|
|
signer.address,
|
|
SELECTED,
|
|
),
|
|
).toThrow(/fee per gas far above any plausible value/);
|
|
});
|
|
|
|
test("assertWithinCeilings is the same check on either side of the screen", () => {
|
|
expect(() =>
|
|
assertWithinCeilings({ gasLimit: MAX_GAS_LIMIT + 1n }),
|
|
).toThrow(/gas limit no network this wallet supports/);
|
|
for (const key of [
|
|
"gasPrice",
|
|
"maxFeePerGas",
|
|
"maxPriorityFeePerGas",
|
|
]) {
|
|
expect(() =>
|
|
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
|
|
).toThrow(/fee per gas far above any plausible value/);
|
|
}
|
|
expect(() =>
|
|
assertWithinCeilings({
|
|
gasLimit: MAX_GAS_LIMIT,
|
|
maxFeePerGas: MAX_FEE_PER_GAS,
|
|
maxPriorityFeePerGas: MAX_FEE_PER_GAS,
|
|
}),
|
|
).not.toThrow();
|
|
// Nothing to bound is not a failure: a type 2 approval carries no gas
|
|
// price, and a bare object must not be refused for lacking one.
|
|
expect(() => assertWithinCeilings({})).not.toThrow();
|
|
});
|
|
|
|
test("every field mismatch is a refusal, not a warning", async () => {
|
|
const raw = await signedWith({ nonce: 8 });
|
|
try {
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
|
throw new Error("expected a rejection");
|
|
} catch (e) {
|
|
expect(e.approvalMismatch).toBe(true);
|
|
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// The transaction type decides which fields exist, so an artifact of a type
|
|
// this wallet does not sign carries consequences the approval cannot describe
|
|
// and none of the field comparisons can see. The refusal has to come from the
|
|
// type allowlist rather than from a field comparison, so these run against an
|
|
// approval whose every other field matches the artifact exactly.
|
|
describe("verifySignedTx transaction type", () => {
|
|
const BARE_REQUEST = {
|
|
from: signer.address,
|
|
to: RECIPIENT,
|
|
value: "0x2386f26fc10000",
|
|
data: "0x",
|
|
};
|
|
const BARE_APPROVAL = approvedFor(BARE_REQUEST);
|
|
|
|
// An EIP-7702 artifact that pays the approved amount to the approved
|
|
// recipient and, in the same transaction, installs the attacker's code at
|
|
// the signer's own account for good. Every field the approval screen shows
|
|
// matches; only the type and the authorization list do not.
|
|
test("refuses a type 4 artifact that delegates the signer's own account", async () => {
|
|
const authorization = await signer.authorize({
|
|
address: OTHER_RECIPIENT,
|
|
chainId: 1,
|
|
nonce: 8,
|
|
});
|
|
const raw = await signedFor(BARE_REQUEST, signer, {
|
|
type: 4,
|
|
authorizationList: [authorization],
|
|
});
|
|
const parsed = Transaction.from(raw);
|
|
expect(parsed.type).toBe(4);
|
|
expect(parsed.authorizationList[0].address).toBe(OTHER_RECIPIENT);
|
|
expect(() =>
|
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
|
).toThrow(/type this wallet does not sign/);
|
|
});
|
|
|
|
test("refuses a type 3 blob artifact", async () => {
|
|
const raw = await signedFor(BARE_REQUEST, signer, {
|
|
type: 3,
|
|
maxFeePerBlobGas: 1000000000n,
|
|
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
|
});
|
|
expect(Transaction.from(raw).type).toBe(3);
|
|
expect(() =>
|
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
|
).toThrow(/type this wallet does not sign/);
|
|
});
|
|
|
|
test("refuses every type outside the allowlist, not just the known ones", async () => {
|
|
for (const type of [3, 4]) {
|
|
expect(ALLOWED_TX_TYPES).not.toContain(type);
|
|
}
|
|
expect(ALLOWED_TX_TYPES).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
test("a type refusal is a refusal, not a warning", async () => {
|
|
const authorization = await signer.authorize({
|
|
address: OTHER_RECIPIENT,
|
|
chainId: 1,
|
|
nonce: 8,
|
|
});
|
|
const raw = await signedFor(BARE_REQUEST, signer, {
|
|
type: 4,
|
|
authorizationList: [authorization],
|
|
});
|
|
try {
|
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED);
|
|
throw new Error("expected a rejection");
|
|
} catch (e) {
|
|
expect(e.approvalMismatch).toBe(true);
|
|
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
|
}
|
|
});
|
|
|
|
test("accepts a legacy type 0 transaction", async () => {
|
|
const legacy = {
|
|
type: 0,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
};
|
|
const raw = await signedFor(BARE_REQUEST, signer, legacy);
|
|
expect(() =>
|
|
verifySignedTx(
|
|
raw,
|
|
approvedFor(BARE_REQUEST, legacy),
|
|
signer.address,
|
|
SELECTED,
|
|
),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("accepts a type 1 transaction whose access list is the approved one", async () => {
|
|
const overrides = {
|
|
type: 1,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
|
|
};
|
|
const raw = await signedFor(BARE_REQUEST, signer, overrides);
|
|
expect(Transaction.from(raw).type).toBe(1);
|
|
expect(() =>
|
|
verifySignedTx(
|
|
raw,
|
|
approvedFor(BARE_REQUEST, overrides),
|
|
signer.address,
|
|
SELECTED,
|
|
),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("refuses an access list the approval never carried", async () => {
|
|
const raw = await signedFor(BARE_REQUEST, signer, {
|
|
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
|
|
});
|
|
expect(() =>
|
|
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
|
).toThrow(/approved access list/);
|
|
});
|
|
|
|
test("treats an absent access list and an empty one as the same thing", async () => {
|
|
const approved = { ...BARE_APPROVAL };
|
|
delete approved.accessList;
|
|
expect(approved.accessList).toBeUndefined();
|
|
const raw = await signedFor(BARE_REQUEST, signer, {});
|
|
expect(Transaction.from(raw).accessList).toEqual([]);
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// The allowlist is only exhaustive while it accounts for every field an
|
|
// artifact can carry. These tests are what makes that claim checkable rather
|
|
// than asserted.
|
|
describe("verifySignedTx exhaustiveness", () => {
|
|
// Every accessor ethers exposes on a parsed transaction, and where this
|
|
// module deals with it. If an ethers upgrade adds a transaction field,
|
|
// this fails and forces a decision about it instead of letting it default
|
|
// to unchecked.
|
|
test("every field ethers can parse is accounted for", () => {
|
|
const derived = [
|
|
// Recovered from the signature or computed from the payload, not
|
|
// independent content: covered by the signer check and by the
|
|
// fields below.
|
|
"from",
|
|
"fromPublicKey",
|
|
"hash",
|
|
"serialized",
|
|
"signature",
|
|
"type",
|
|
"typeName",
|
|
"unsignedHash",
|
|
"unsignedSerialized",
|
|
// Blob sidecar machinery, meaningful only alongside `blobs`,
|
|
// which is refused outright.
|
|
"kzg",
|
|
"blobWrapperVersion",
|
|
];
|
|
const accounted = new Set([
|
|
...derived,
|
|
...FORBIDDEN_FIELDS.map((f) => f.key),
|
|
...Object.values(SERIALIZED_FIELDS).flat(),
|
|
]);
|
|
const exposed = Object.getOwnPropertyNames(Transaction.prototype)
|
|
.filter((name) => {
|
|
const d = Object.getOwnPropertyDescriptor(
|
|
Transaction.prototype,
|
|
name,
|
|
);
|
|
return d && typeof d.get === "function";
|
|
})
|
|
.sort();
|
|
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
|
|
});
|
|
|
|
// The comparison loop runs over the fields a type serializes and refuses a
|
|
// field it has no comparator for. That refusal is unreachable only while
|
|
// the table covers the whole of SERIALIZED_FIELDS, so the coverage is
|
|
// pinned here rather than assumed: adding a field to a type without a
|
|
// comparator would otherwise turn every transaction of that type into a
|
|
// refusal, and adding a comparator without the field would be a check that
|
|
// never runs.
|
|
test("every field a type serializes has a comparator", () => {
|
|
const serialized = new Set(
|
|
Object.values(SERIALIZED_FIELDS).flat().sort(),
|
|
);
|
|
expect([...serialized].filter((key) => !APPROVED_FIELDS[key])).toEqual(
|
|
[],
|
|
);
|
|
expect(
|
|
Object.keys(APPROVED_FIELDS).filter((key) => !serialized.has(key)),
|
|
).toEqual([]);
|
|
});
|
|
|
|
// The two layers behind the type allowlist. Nothing reachable through
|
|
// verifySignedTx can trip either of them while the allowlist holds — that
|
|
// is what they are for — so they are exercised directly rather than taken
|
|
// on trust.
|
|
test("a forbidden field is refused even on an allowed type", async () => {
|
|
const authorization = await signer.authorize({
|
|
address: OTHER_RECIPIENT,
|
|
chainId: 1,
|
|
nonce: 8,
|
|
});
|
|
const carriers = {
|
|
authorizationList: [authorization],
|
|
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
|
blobs: ["0x00"],
|
|
maxFeePerBlobGas: 1n,
|
|
};
|
|
for (const key of Object.keys(carriers)) {
|
|
expect(FORBIDDEN_FIELDS.map((f) => f.key)).toContain(key);
|
|
let thrown;
|
|
try {
|
|
assertNoForbiddenFields({ type: 2, [key]: carriers[key] });
|
|
throw new Error("expected a rejection");
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
expect(thrown.approvalMismatch).toBe(true);
|
|
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
|
}
|
|
expect(() => assertNoForbiddenFields({ type: 2 })).not.toThrow();
|
|
});
|
|
|
|
// Stands in for a future ethers that parses a field this module does not
|
|
// know about onto an allowed type: every field the module checks is
|
|
// identical, and the bytes are not.
|
|
test("an artifact carrying more than the checked fields is refused", async () => {
|
|
const parsed = Transaction.from(await signedWith({}));
|
|
const smuggled = { type: parsed.type };
|
|
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
|
smuggled[key] = parsed[key];
|
|
}
|
|
smuggled.unsignedSerialized = parsed.unsignedSerialized + "ff";
|
|
expect(() => assertNothingUnchecked(smuggled)).toThrow(
|
|
/beyond the fields that were checked/,
|
|
);
|
|
expect(() => assertNothingUnchecked(parsed)).not.toThrow();
|
|
});
|
|
|
|
// The closing check rebuilds the artifact from the fields the module
|
|
// compared and compares the bytes, so an artifact carrying anything else
|
|
// is refused without the module having to name it. Assert the rebuild is
|
|
// faithful for every accepted shape, since a rebuild that dropped a
|
|
// legitimate field would refuse honest transactions.
|
|
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
|
|
const shapes = [
|
|
{
|
|
type: 0,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
},
|
|
{
|
|
type: 1,
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: null,
|
|
maxPriorityFeePerGas: null,
|
|
accessList: [
|
|
{
|
|
address: RECIPIENT,
|
|
storageKeys: ["0x" + "11".repeat(32)],
|
|
},
|
|
],
|
|
},
|
|
{},
|
|
];
|
|
for (const overrides of shapes) {
|
|
const raw = await signedFor(TX_PARAMS, signer, overrides);
|
|
const parsed = verifySignedTx(
|
|
raw,
|
|
approvedFor(TX_PARAMS, overrides),
|
|
signer.address,
|
|
SELECTED,
|
|
);
|
|
const fields = { type: parsed.type };
|
|
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
|
fields[key] = parsed[key];
|
|
}
|
|
expect(Transaction.from(fields).unsignedSerialized).toBe(
|
|
parsed.unsignedSerialized,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Every comparison above runs against the decode, but the string that is
|
|
// handed to broadcastTransaction() is the artifact. An encoding the decoder
|
|
// normalizes away therefore checks as one transaction and broadcasts as
|
|
// different bytes, so the artifact must be the canonical encoding of itself.
|
|
describe("verifySignedTx canonical encoding", () => {
|
|
// Re-encode a signed type-2 artifact with a leading zero byte on the RLP
|
|
// value field. It decodes to exactly the approved transaction — same
|
|
// value, same signer, same everything the field comparisons look at — and
|
|
// it is not the same string.
|
|
async function nonCanonical() {
|
|
const raw = await signedWith({});
|
|
const items = decodeRlp("0x" + raw.slice(4));
|
|
// type 2 payload order: chainId, nonce, maxPriorityFeePerGas,
|
|
// maxFeePerGas, gasLimit, to, value, data, accessList, then the
|
|
// signature.
|
|
const padded = items.slice();
|
|
padded[6] = "0x00" + items[6].slice(2);
|
|
return "0x02" + encodeRlp(padded).slice(2);
|
|
}
|
|
|
|
test("the mutation decodes to the approved transaction and is not it", async () => {
|
|
const raw = await signedWith({});
|
|
const mutated = await nonCanonical();
|
|
const parsed = Transaction.from(mutated);
|
|
expect(mutated).not.toBe(raw);
|
|
expect(mutated.length).toBeGreaterThan(raw.length);
|
|
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
|
|
expect(parsed.from).toBe(signer.address);
|
|
expect(parsed.serialized).not.toBe(mutated);
|
|
});
|
|
|
|
test("refuses an artifact that is not its own canonical encoding", async () => {
|
|
const mutated = await nonCanonical();
|
|
expect(() =>
|
|
verifySignedTx(mutated, APPROVED, signer.address, SELECTED),
|
|
).toThrow(/not encoded canonically/);
|
|
});
|
|
|
|
test("assertCanonicalBytes accepts what ethers itself produced", async () => {
|
|
const raw = await signedWith({});
|
|
expect(() =>
|
|
assertCanonicalBytes(Transaction.from(raw), raw),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("hex case is not part of the encoding", async () => {
|
|
const raw = await signedWith({});
|
|
const upper = "0x" + raw.slice(2).toUpperCase();
|
|
expect(() =>
|
|
verifySignedTx(upper, APPROVED, signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// The approval and the artifact spell the same values differently. None of
|
|
// these differences is tampering, so none may refuse the signature.
|
|
describe("verifySignedTx normalization", () => {
|
|
test("accepts a decimal chain id against a hex selected network", async () => {
|
|
const raw = await signedWith({});
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, 1),
|
|
).not.toThrow();
|
|
expect(() =>
|
|
verifySignedTx(raw, APPROVED, signer.address, "1"),
|
|
).not.toThrow();
|
|
});
|
|
|
|
// The approved transaction crosses to the popup as JSON, so it comes back
|
|
// spelled in hex quantities rather than in the bigints it was populated
|
|
// with. None of that is tampering.
|
|
test("accepts an approval spelled as the wire spells it", async () => {
|
|
const raw = await signedWith({});
|
|
const wire = {
|
|
...APPROVED,
|
|
chainId: "0x1",
|
|
nonce: "0x7",
|
|
gasLimit: "0x186a0",
|
|
maxFeePerGas: "0x77359400",
|
|
maxPriorityFeePerGas: "0x3b9aca00",
|
|
value: "0x2386f26fc10000",
|
|
};
|
|
expect(() =>
|
|
verifySignedTx(raw, wire, signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("accepts quantities spelled as hex, decimal, number and bigint", async () => {
|
|
const raw = await signedWith({});
|
|
for (const maxFee of [
|
|
"0x77359400",
|
|
"2000000000",
|
|
2000000000,
|
|
2000000000n,
|
|
]) {
|
|
expect(() =>
|
|
verifySignedTx(
|
|
raw,
|
|
{ ...APPROVED, maxFeePerGas: maxFee },
|
|
signer.address,
|
|
SELECTED,
|
|
),
|
|
).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("accepts an approval whose recipient case differs", async () => {
|
|
const raw = await signedWith({});
|
|
const approved = { ...APPROVED, to: RECIPIENT.toLowerCase() };
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("accepts absent call data against 0x", async () => {
|
|
const params = { to: RECIPIENT, value: "0x0" };
|
|
const approved = approvedFor(params);
|
|
delete approved.data;
|
|
const raw = await signedFor({ ...params, data: "0x" });
|
|
expect(() =>
|
|
verifySignedTx(raw, approved, signer.address, SELECTED),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("refuses an approved quantity that is not a number", async () => {
|
|
const raw = await signedWith({});
|
|
expect(() =>
|
|
verifySignedTx(
|
|
raw,
|
|
{ ...APPROVED, maxFeePerGas: "cheap" },
|
|
signer.address,
|
|
SELECTED,
|
|
),
|
|
).toThrow(/is not a number/);
|
|
});
|
|
|
|
// The value is page-controlled. A refusal is correct; a raw BigInt
|
|
// conversion error is not, because it is not a mismatch, so it would be
|
|
// reported retryable and leave the approval unspent behind a live button
|
|
// that can never succeed.
|
|
test("refuses an approved value that is not a number, as a mismatch", async () => {
|
|
const raw = await signedWith({});
|
|
for (const value of ["cheap", 1.5, "1e18", {}]) {
|
|
let thrown;
|
|
try {
|
|
verifySignedTx(
|
|
raw,
|
|
{ ...APPROVED, value },
|
|
signer.address,
|
|
SELECTED,
|
|
);
|
|
throw new Error("expected a rejection");
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
expect(thrown.approvalMismatch).toBe(true);
|
|
expect(thrown.message).toMatch(/approved value is not a number/);
|
|
expect(failureIsRetryable(thrown)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("refuses an approved access list that is not an access list", async () => {
|
|
const raw = await signedWith({});
|
|
expect(() =>
|
|
verifySignedTx(
|
|
raw,
|
|
{ ...APPROVED, accessList: ["nope"] },
|
|
signer.address,
|
|
SELECTED,
|
|
),
|
|
).toThrow(/not a valid access list/);
|
|
});
|
|
});
|
|
|
|
const TYPED_DATA = JSON.stringify({
|
|
domain: {
|
|
name: "AutistMask Test",
|
|
version: "1",
|
|
chainId: 1,
|
|
verifyingContract: OTHER_RECIPIENT,
|
|
},
|
|
primaryType: "Mail",
|
|
types: {
|
|
EIP712Domain: [
|
|
{ name: "name", type: "string" },
|
|
{ name: "version", type: "string" },
|
|
{ name: "chainId", type: "uint256" },
|
|
{ name: "verifyingContract", type: "address" },
|
|
],
|
|
Mail: [
|
|
{ name: "from", type: "address" },
|
|
{ name: "to", type: "address" },
|
|
{ name: "contents", type: "string" },
|
|
],
|
|
},
|
|
message: {
|
|
from: signer.address,
|
|
to: RECIPIENT,
|
|
contents: "hello",
|
|
},
|
|
});
|
|
|
|
describe("verifySignature", () => {
|
|
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
|
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
|
const personalParams = {
|
|
method: "personal_sign",
|
|
message: MESSAGE,
|
|
from: signer.address,
|
|
};
|
|
const typedParams = {
|
|
method: "eth_signTypedData_v4",
|
|
typedData: TYPED_DATA,
|
|
from: signer.address,
|
|
};
|
|
|
|
async function signPersonal(withWallet) {
|
|
return (withWallet || signer).signMessage(
|
|
Buffer.from(MESSAGE.slice(2), "hex"),
|
|
);
|
|
}
|
|
|
|
async function signTyped(withWallet) {
|
|
const { domain, types, message } = JSON.parse(TYPED_DATA);
|
|
delete types.EIP712Domain;
|
|
return (withWallet || signer).signTypedData(domain, types, message);
|
|
}
|
|
|
|
test("accepts a personal_sign signature from the approved address", async () => {
|
|
const signature = await signPersonal();
|
|
expect(verifySignature(personalParams, signature, signer.address)).toBe(
|
|
signer.address,
|
|
);
|
|
});
|
|
|
|
test("accepts an eth_sign signature the same way", async () => {
|
|
const signature = await signPersonal();
|
|
const params = { ...personalParams, method: "eth_sign" };
|
|
expect(() =>
|
|
verifySignature(params, signature, signer.address),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("accepts a typed data signature from the approved address", async () => {
|
|
const signature = await signTyped();
|
|
expect(verifySignature(typedParams, signature, signer.address)).toBe(
|
|
signer.address,
|
|
);
|
|
});
|
|
|
|
test("does not mutate the approved typed data while verifying", async () => {
|
|
const signature = await signTyped();
|
|
const before = typedParams.typedData;
|
|
verifySignature(typedParams, signature, signer.address);
|
|
expect(typedParams.typedData).toBe(before);
|
|
expect(
|
|
JSON.parse(typedParams.typedData).types.EIP712Domain,
|
|
).toBeDefined();
|
|
});
|
|
|
|
test("rejects a personal_sign signature from a different address", async () => {
|
|
const signature = await signPersonal(other);
|
|
expect(() =>
|
|
verifySignature(personalParams, signature, signer.address),
|
|
).toThrow(/different address/);
|
|
});
|
|
|
|
test("rejects a typed data signature from a different address", async () => {
|
|
const signature = await signTyped(other);
|
|
expect(() =>
|
|
verifySignature(typedParams, signature, signer.address),
|
|
).toThrow(/different address/);
|
|
});
|
|
|
|
test("rejects a signature over a different message", async () => {
|
|
const signature = await signer.signMessage(
|
|
Buffer.from("00112233", "hex"),
|
|
);
|
|
expect(() =>
|
|
verifySignature(personalParams, signature, signer.address),
|
|
).toThrow(/different address/);
|
|
});
|
|
|
|
test("rejects a missing or malformed signature", async () => {
|
|
expect(() =>
|
|
verifySignature(personalParams, undefined, signer.address),
|
|
).toThrow(/missing or malformed/);
|
|
expect(() =>
|
|
verifySignature(personalParams, "0x1234", signer.address),
|
|
).toThrow(/could not be verified/);
|
|
});
|
|
});
|
|
|
|
// What happens after a signing attempt fails: the background keeps the
|
|
// approval for anything the user can correct, and the popup only offers the
|
|
// button again when it did.
|
|
describe("signing failure and retry", () => {
|
|
test("a failure that is not a mismatch leaves the approval retryable", () => {
|
|
expect(failureIsRetryable(new Error("The node is unreachable."))).toBe(
|
|
true,
|
|
);
|
|
expect(failureIsRetryable(undefined)).toBe(true);
|
|
});
|
|
|
|
test("a mismatch spends the approval", async () => {
|
|
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
|
try {
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
|
throw new Error("expected a rejection");
|
|
} catch (e) {
|
|
expect(failureIsRetryable(e)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("a retryable failure keeps the button usable and says only what failed", () => {
|
|
const outcome = describeSigningFailure(
|
|
{ error: "The node rejected the transaction.", retryable: true },
|
|
"The transaction could not be sent.",
|
|
);
|
|
expect(outcome.retryable).toBe(true);
|
|
expect(outcome.message).toBe("The node rejected the transaction.");
|
|
});
|
|
|
|
test("a refusal tells the user to start again from the site", () => {
|
|
const outcome = describeSigningFailure(
|
|
{
|
|
error: "The signed transaction does not go to the approved recipient.",
|
|
retryable: false,
|
|
},
|
|
"The transaction could not be sent.",
|
|
);
|
|
expect(outcome.retryable).toBe(false);
|
|
expect(outcome.message).toMatch(/start it again from the site\.$/);
|
|
});
|
|
|
|
test("a refusal for an attempt already running does not say to start again", () => {
|
|
const outcome = describeSigningFailure(
|
|
{
|
|
error: "This request is already being signed.",
|
|
retryable: false,
|
|
stage: "inflight",
|
|
},
|
|
"The message could not be signed.",
|
|
);
|
|
expect(outcome.retryable).toBe(false);
|
|
expect(outcome.message).not.toMatch(/start it again from the site/);
|
|
expect(outcome.message).toMatch(/first attempt is still running/);
|
|
});
|
|
|
|
test("a response the background never sent is treated as a spent approval", () => {
|
|
const outcome = describeSigningFailure(
|
|
undefined,
|
|
"The transaction could not be sent.",
|
|
);
|
|
expect(outcome.retryable).toBe(false);
|
|
expect(outcome.message).toMatch(/^The transaction could not be sent\./);
|
|
});
|
|
|
|
test("every failure message is a full sentence", () => {
|
|
const outcome = describeSigningFailure(
|
|
{ error: "The node is on fire", retryable: true },
|
|
"The transaction could not be sent.",
|
|
);
|
|
expect(outcome.message).toMatch(/^[A-Z].*\.$/);
|
|
});
|
|
|
|
test("a popup that could not sign leaves the approval standing", () => {
|
|
const outcome = describeTxFailure(
|
|
TX_STAGE_SIGN,
|
|
"That password is incorrect. Please try again.",
|
|
);
|
|
expect(outcome.retryable).toBe(true);
|
|
expect(outcome.spendApproval).toBe(false);
|
|
expect(outcome.error).toMatch(/password is incorrect/);
|
|
});
|
|
|
|
test("a mismatch found at verification spends the approval", async () => {
|
|
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
|
let outcome;
|
|
try {
|
|
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
|
} catch (e) {
|
|
outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
|
}
|
|
expect(outcome.retryable).toBe(false);
|
|
expect(outcome.spendApproval).toBe(true);
|
|
});
|
|
|
|
test("a failure before the check ran is still retryable", () => {
|
|
const outcome = describeTxFailure(
|
|
TX_STAGE_VERIFY,
|
|
new Error("The wallet state could not be read."),
|
|
);
|
|
expect(outcome.retryable).toBe(true);
|
|
expect(outcome.spendApproval).toBe(false);
|
|
});
|
|
|
|
// A broadcast that throws after the node took the transaction is routine:
|
|
// a timeout, a dropped response, a node answering "already known". The
|
|
// popup's retry does not re-broadcast the same bytes — it re-populates and
|
|
// re-signs at a freshly fetched nonce — so a retryable broadcast failure
|
|
// would put the approved transfer on the chain twice.
|
|
test("a failed broadcast is terminal, whatever the node said", () => {
|
|
for (const message of [
|
|
"already known",
|
|
"timeout of 30000ms exceeded",
|
|
"could not coalesce error",
|
|
]) {
|
|
const outcome = describeTxFailure(
|
|
TX_STAGE_BROADCAST,
|
|
new Error(message),
|
|
);
|
|
expect(outcome.retryable).toBe(false);
|
|
expect(outcome.spendApproval).toBe(true);
|
|
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
|
expect(outcome.error).toBe(message);
|
|
}
|
|
});
|
|
|
|
// The one broadcast failure that is not ambiguous. The node answered, and
|
|
// its answer was that the nonce was already spoken for, so this
|
|
// transaction is not in a mempool anywhere.
|
|
test("a nonce the node refused is classified however it was worded", () => {
|
|
for (const err of [
|
|
new Error("nonce too low"),
|
|
new Error("replacement transaction underpriced"),
|
|
Object.assign(new Error("could not coalesce error"), {
|
|
code: "NONCE_EXPIRED",
|
|
}),
|
|
Object.assign(new Error("could not coalesce error"), {
|
|
code: "REPLACEMENT_UNDERPRICED",
|
|
}),
|
|
// The shape ethers hands up when it could not classify the node's
|
|
// error itself: the node's own words are nested underneath.
|
|
Object.assign(new Error("could not coalesce error"), {
|
|
info: { error: { code: -32000, message: "OldNonce" } },
|
|
}),
|
|
]) {
|
|
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
|
|
expect(
|
|
describeSigningFailure(
|
|
outcome,
|
|
"The transaction could not be sent.",
|
|
).message,
|
|
).toMatch(/did not reach the network/);
|
|
expect(outcome.retryable).toBe(false);
|
|
expect(outcome.spendApproval).toBe(true);
|
|
expect(outcome.error).toBe(NONCE_COLLISION_MESSAGE);
|
|
expect(outcome.stage).toBe(TX_STAGE_NONCE);
|
|
expect(isNonceCollision(err)).toBe(true);
|
|
}
|
|
});
|
|
|
|
// A node that says it knows the transaction has it, so it did reach the
|
|
// network and the ambiguous wording is the correct one.
|
|
test("already known is not a nonce collision", () => {
|
|
const err = new Error("already known");
|
|
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
|
|
expect(
|
|
describeSigningFailure(
|
|
outcome,
|
|
"The transaction could not be sent.",
|
|
).message,
|
|
).toMatch(/may still have reached the network/);
|
|
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
|
expect(isNonceCollision(err)).toBe(false);
|
|
});
|
|
|
|
test("a nonce collision says the transaction did not reach the network", () => {
|
|
const outcome = describeTxFailure(
|
|
TX_STAGE_BROADCAST,
|
|
new Error("nonce too low"),
|
|
);
|
|
const copy = describeSigningFailure(
|
|
outcome,
|
|
"The transaction could not be sent.",
|
|
);
|
|
expect(copy.retryable).toBe(false);
|
|
expect(copy.message).toMatch(/did not reach the network/);
|
|
expect(copy.message).not.toMatch(/may still have reached the network/);
|
|
expect(copy.message).toMatch(/Please send it again from the site\.$/);
|
|
expect(copy.message).toMatch(/^[A-Z].*\.$/);
|
|
});
|
|
|
|
test("a failed broadcast does not tell the user to send it again", () => {
|
|
const outcome = describeSigningFailure(
|
|
{
|
|
error: "The node did not answer.",
|
|
retryable: false,
|
|
stage: TX_STAGE_BROADCAST,
|
|
},
|
|
"The transaction could not be sent.",
|
|
);
|
|
expect(outcome.retryable).toBe(false);
|
|
expect(outcome.message).toMatch(/may still have reached the network/);
|
|
expect(outcome.message).not.toMatch(/start it again from the site/);
|
|
});
|
|
});
|
|
|
|
// End-to-end over the messaging boundary, without a browser: the background
|
|
// populates the transaction, the object that produces crosses to the popup as
|
|
// JSON and is signed there, and the artifact goes back to the exact check the
|
|
// background runs before it broadcasts. Only what each side puts on the wire is
|
|
// passed along, so this also pins down that the wire payloads are sufficient on
|
|
// their own.
|
|
describe("background population to popup signing to verification", () => {
|
|
// Stand-in for the JSON-RPC provider. populateTransaction only needs the
|
|
// nonce, the gas estimate, the network and the fee data.
|
|
const fakeProvider = {
|
|
getNetwork: async () => Network.from(1),
|
|
getTransactionCount: async () => 7,
|
|
estimateGas: async () => 21000n,
|
|
getFeeData: async () => ({
|
|
gasPrice: 2000000000n,
|
|
maxFeePerGas: 2000000000n,
|
|
maxPriorityFeePerGas: 1000000000n,
|
|
}),
|
|
};
|
|
|
|
// A private-key wallet as it is persisted in state, so the test goes
|
|
// through getSignerForAddress() the way the popup does.
|
|
const walletData = { type: "privkey" };
|
|
|
|
// What the background does before the approval window opens.
|
|
async function backgroundPrepares(txParams) {
|
|
const approvedTx = await prepareApprovalTx(
|
|
fakeProvider,
|
|
signer.address,
|
|
txParams,
|
|
);
|
|
// Extension messaging is JSON; the popup sees the other side of it.
|
|
return JSON.parse(JSON.stringify(approvedTx));
|
|
}
|
|
|
|
// What the popup does with it: signs it as given, populating nothing.
|
|
async function popupSigns(approvedTx) {
|
|
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
|
|
return localSigner.signTransaction({ ...approvedTx });
|
|
}
|
|
|
|
test("the populated transaction is what gets signed and what gets checked", async () => {
|
|
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
|
const rawSignedTx = await popupSigns(approvedTx);
|
|
const parsed = verifySignedTx(
|
|
rawSignedTx,
|
|
approvedTx,
|
|
signer.address,
|
|
SELECTED,
|
|
);
|
|
expect(parsed.nonce).toBe(7);
|
|
expect(parsed.chainId).toBe(1n);
|
|
expect(parsed.gasLimit).toBe(21000n);
|
|
expect(parsed.maxFeePerGas).toBe(2000000000n);
|
|
expect(parsed.to).toBe(RECIPIENT);
|
|
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
|
|
expect(parsed.data).toBe(TX_PARAMS.data);
|
|
expect(parsed.signature).not.toBeNull();
|
|
// Every field the screen shows, and the artifact, are the same numbers.
|
|
expect(BigInt(approvedTx.nonce)).toBe(BigInt(parsed.nonce));
|
|
expect(BigInt(approvedTx.gasLimit)).toBe(parsed.gasLimit);
|
|
expect(BigInt(approvedTx.maxFeePerGas)).toBe(parsed.maxFeePerGas);
|
|
expect(BigInt(approvedTx.maxPriorityFeePerGas)).toBe(
|
|
parsed.maxPriorityFeePerGas,
|
|
);
|
|
});
|
|
|
|
test("the wire payload carries no password and no secret", async () => {
|
|
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
|
const rawSignedTx = await popupSigns(approvedTx);
|
|
const payload = {
|
|
type: "AUTISTMASK_TX_RESPONSE",
|
|
id: "test-approval-id",
|
|
approved: true,
|
|
rawSignedTx,
|
|
};
|
|
expect(Object.keys(payload).sort()).toEqual([
|
|
"approved",
|
|
"id",
|
|
"rawSignedTx",
|
|
"type",
|
|
]);
|
|
const wire = JSON.stringify(payload).toLowerCase();
|
|
expect(wire).not.toContain("password");
|
|
expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase());
|
|
});
|
|
|
|
// The popup is the component whose compromise this check exists to detect,
|
|
// so it is given the approved transaction and signs something else.
|
|
test("a popup that signs a different fee than it was given is refused", async () => {
|
|
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
|
const rawSignedTx = await popupSigns({
|
|
...approvedTx,
|
|
maxFeePerGas: "0x3b9aca000",
|
|
});
|
|
expect(() =>
|
|
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
|
|
).toThrow(/approved maximum fee per gas/);
|
|
});
|
|
|
|
test("a popup that signs a different nonce than it was given is refused", async () => {
|
|
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
|
const rawSignedTx = await popupSigns({ ...approvedTx, nonce: "0x8" });
|
|
expect(() =>
|
|
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
|
|
).toThrow(/approved nonce/);
|
|
});
|
|
|
|
test("the background rejects a transaction the popup did not approve", async () => {
|
|
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
|
const rawSignedTx = await popupSigns({
|
|
...approvedTx,
|
|
to: OTHER_RECIPIENT,
|
|
});
|
|
expect(() =>
|
|
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
|
|
).toThrow(/approved recipient/);
|
|
});
|
|
|
|
test("the background rejects a transaction populated on another network", async () => {
|
|
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
|
const rawSignedTx = await popupSigns(approvedTx);
|
|
expect(() =>
|
|
verifySignedTx(rawSignedTx, approvedTx, signer.address, SEPOLIA),
|
|
).toThrow(/different network than the one that is selected/);
|
|
});
|
|
|
|
// ethers refuses to sign for an address that is not the key's own, so a
|
|
// popup working from the approved object cannot quietly sign as whichever
|
|
// 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, 0, OTHER_KEY);
|
|
await expect(
|
|
otherSigner.signTransaction({ ...approvedTx }),
|
|
).rejects.toThrow(/from address mismatch/);
|
|
});
|
|
});
|