const { Network, Transaction, Wallet, decodeRlp, encodeRlp, } = require("ethers"); const { verifySignedTx, verifySignature, assertNoForbiddenFields, assertNothingUnchecked, assertCanonicalBytes, assertWithinCeilings, sameAddress, failureIsRetryable, describeTxFailure, describeSigningFailure, ALLOWED_TX_TYPES, SERIALIZED_FIELDS, FORBIDDEN_FIELDS, APPROVED_FIELDS, TX_STAGE_SIGN, TX_STAGE_VERIFY, TX_STAGE_BROADCAST, 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", "replacement transaction underpriced", ]) { const outcome = describeTxFailure( TX_STAGE_BROADCAST, new Error(message), ); expect(outcome.retryable).toBe(false); expect(outcome.spendApproval).toBe(true); expect(outcome.error).toBe(message); } }); 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/); }); });