const { Network, Transaction, Wallet } = require("ethers"); const { verifySignedTx, verifySignature, assertNoForbiddenFields, assertNothingUnchecked, sameAddress, failureIsRetryable, describeTxFailure, describeSigningFailure, ALLOWED_TX_TYPES, SERIALIZED_FIELDS, FORBIDDEN_FIELDS, TX_STAGE_SIGN, TX_STAGE_VERIFY, TX_STAGE_BROADCAST, MAX_GAS_LIMIT, MAX_FEE_PER_GAS, } = require("../src/shared/approvalVerify"); 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"; // Approved parameters as a dApp would supply them over eth_sendTransaction. 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 approved params. The popup does the same // thing through populateTransaction(); here the fields 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 || {}), }; } 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, TX_PARAMS, 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, params, signer.address, SELECTED), ).not.toThrow(); }); test("accepts an absent value as zero", async () => { const approved = { to: RECIPIENT, data: "0x" }; const raw = await signedFor(approved); expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).not.toThrow(); }); test("accepts call data whose case differs from the approval", async () => { const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" }; const raw = await signedFor(approved); expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).not.toThrow(); }); test("rejects a swapped recipient", async () => { const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT, }); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED), ).toThrow(/approved recipient/); }); test("rejects an inflated value", async () => { const raw = await signedFor({ ...TX_PARAMS, value: "0x4563918244f40000", }); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED), ).toThrow(/approved value/); }); test("rejects substituted call data", async () => { const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" }); expect(() => verifySignedTx(raw, TX_PARAMS, 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, TX_PARAMS, signer.address, SELECTED), ).toThrow(/different address/); }); test("rejects an unsigned transaction", () => { const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized; expect(() => verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED), ).toThrow(/no valid signature/); }); test("rejects a missing or malformed payload", () => { expect(() => verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED), ).toThrow(/missing or malformed/); expect(() => verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED), ).toThrow(/missing or malformed/); expect(() => verifySignedTx("0xc0ffee", TX_PARAMS, 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, TX_PARAMS, signer.address, SELECTED); throw new Error("expected a rejection"); } catch (e) { expect(e.message).toMatch(/^[A-Z].*\.$/); } }); }); // 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, TX_PARAMS, 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 dApp asked for a // different chain, so the artifact is not what was approved. const approved = { ...TX_PARAMS, 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, TX_PARAMS, signer.address, undefined), ).toThrow(/selected network is unknown/); }); test("rejects a substituted nonce", async () => { const approved = { ...TX_PARAMS, nonce: 7 }; const raw = await signedWith({ nonce: 8 }); expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).toThrow(/approved nonce/); }); test("rejects a substituted gas limit", async () => { const approved = { ...TX_PARAMS, gasLimit: "0x186a0" }; 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 approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" }; 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 approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" }; 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 approved = { ...TX_PARAMS, gasPrice: "0x77359400" }; const legacy = { type: 0, gasPrice: 9000000000n, maxFeePerGas: null, maxPriorityFeePerGas: null, }; const raw = await signedWith(legacy); 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 = { ...TX_PARAMS, gasPrice: "0x77359400" }; 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 approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" }; const raw = await signedWith({ type: 0, gasPrice: 2000000000n, maxFeePerGas: null, maxPriorityFeePerGas: null, }); expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).toThrow(/approved fee mechanism/); }); test("rejects a gas limit above anything a supported network accepts", async () => { const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n }); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED), ).toThrow(/gas limit no network this wallet supports/); }); test("rejects an absurd fee per gas the approval never fixed", async () => { const raw = await signedWith({ maxFeePerGas: MAX_FEE_PER_GAS + 1n, maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n, }); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED), ).toThrow(/fee per gas far above any plausible value/); }); test("every field mismatch is a refusal, not a warning", async () => { const raw = await signedWith({ nonce: 8 }); try { verifySignedTx( raw, { ...TX_PARAMS, nonce: 7 }, 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 approval used here is the // ordinary dApp shape with no fee fields — the common case, since // populateTransaction() fills them — which is exactly the case the // fee-mechanism check cannot catch by accident. describe("verifySignedTx transaction type", () => { const BARE_APPROVAL = { from: signer.address, to: RECIPIENT, value: "0x2386f26fc10000", data: "0x", }; // 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_APPROVAL, 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_APPROVAL, 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_APPROVAL, 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 approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" }; const raw = await signedFor(approved, signer, { type: 0, gasPrice: 2000000000n, maxFeePerGas: null, maxPriorityFeePerGas: null, }); expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).not.toThrow(); }); test("accepts a type 1 transaction whose access list is the approved one", async () => { const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }]; const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400", accessList, }; const raw = await signedFor(approved, signer, { type: 1, gasPrice: 2000000000n, maxFeePerGas: null, maxPriorityFeePerGas: null, accessList, }); expect(Transaction.from(raw).type).toBe(1); expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).not.toThrow(); }); test("refuses an access list the approval never carried", async () => { const raw = await signedFor(BARE_APPROVAL, 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, accessList: [] }; const raw = await signedFor(BARE_APPROVAL, signer, {}); 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 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 = [ { approved: { ...TX_PARAMS, gasPrice: "0x77359400" }, overrides: { type: 0, gasPrice: 2000000000n, maxFeePerGas: null, maxPriorityFeePerGas: null, }, }, { approved: { ...TX_PARAMS, gasPrice: "0x77359400", accessList: [ { address: RECIPIENT, storageKeys: ["0x" + "11".repeat(32)], }, ], }, overrides: { type: 1, gasPrice: 2000000000n, maxFeePerGas: null, maxPriorityFeePerGas: null, accessList: [ { address: RECIPIENT, storageKeys: ["0x" + "11".repeat(32)], }, ], }, }, { approved: TX_PARAMS, overrides: {} }, ]; for (const shape of shapes) { const raw = await signedFor( shape.approved, signer, shape.overrides, ); const parsed = verifySignedTx( raw, shape.approved, 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, ); } }); }); // 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, TX_PARAMS, signer.address, 1), ).not.toThrow(); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address, "1"), ).not.toThrow(); }); test("accepts an approved chain id written in hex", async () => { const raw = await signedWith({}); const approved = { ...TX_PARAMS, chainId: "0x1" }; expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).not.toThrow(); }); test("accepts a hex nonce against a numeric one", async () => { const raw = await signedWith({ nonce: 7 }); expect(() => verifySignedTx( raw, { ...TX_PARAMS, nonce: "0x7" }, signer.address, SELECTED, ), ).not.toThrow(); }); test("accepts a decimal gas limit against a hex one", async () => { const raw = await signedWith({ gasLimit: 100000n }); expect(() => verifySignedTx( raw, { ...TX_PARAMS, gasLimit: "100000" }, signer.address, SELECTED, ), ).not.toThrow(); }); test("accepts fee fields spelled as hex, decimal, number and bigint", async () => { const raw = await signedWith({}); for (const maxFee of [ "0x77359400", "2000000000", 2000000000, 2000000000n, ]) { expect(() => verifySignedTx( raw, { ...TX_PARAMS, maxFeePerGas: maxFee }, signer.address, SELECTED, ), ).not.toThrow(); } }); test("accepts an approval that fixes no nonce, gas or fee at all", async () => { const raw = await signedWith({}); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED), ).not.toThrow(); }); test("accepts an approval whose recipient case differs", async () => { const raw = await signedWith({}); const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() }; expect(() => verifySignedTx(raw, approved, signer.address, SELECTED), ).not.toThrow(); }); test("accepts absent call data against 0x", async () => { const approved = { to: RECIPIENT, value: "0x0" }; const raw = await signedFor({ ...approved, 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, { ...TX_PARAMS, 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, { ...TX_PARAMS, 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, { ...TX_PARAMS, 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, TX_PARAMS, 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 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, TX_PARAMS, 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: run the exact // sequence the approval popup runs, then hand the artifact to the exact check // the background runs before it broadcasts or resolves. Only what the popup // puts on the wire is passed along, so this also pins down that the wire // payload is sufficient on its own. describe("popup signing sequence to background 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" }; async function popupSignsTx(txParams) { const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY); const connected = localSigner.connect(fakeProvider); const populated = await connected.populateTransaction(txParams); delete populated.from; return connected.signTransaction(populated); } test("a populated, signed transaction is accepted and broadcastable", async () => { const rawSignedTx = await popupSignsTx(TX_PARAMS); const parsed = verifySignedTx( rawSignedTx, TX_PARAMS, signer.address, SELECTED, ); expect(parsed.nonce).toBe(7); expect(parsed.chainId).toBe(1n); expect(parsed.gasLimit).toBe(21000n); 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(); }); test("the wire payload carries no password and no secret", async () => { const rawSignedTx = await popupSignsTx(TX_PARAMS); 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()); }); test("the background rejects a transaction the popup did not approve", async () => { const rawSignedTx = await popupSignsTx({ ...TX_PARAMS, to: OTHER_RECIPIENT, }); expect(() => verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED), ).toThrow(/approved recipient/); }); test("the background rejects a transaction populated on another network", async () => { const rawSignedTx = await popupSignsTx(TX_PARAMS); expect(() => verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA), ).toThrow(/different network than the one that is selected/); }); });