const { Network, Transaction, Wallet } = require("ethers"); const { verifySignedTx, verifySignature, sameAddress, } = 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"; // 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", }; // 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. function txFor(params) { return { chainId: 1, nonce: 7, gasLimit: 100000n, maxFeePerGas: 2000000000n, maxPriorityFeePerGas: 1000000000n, type: 2, to: params.to, value: params.value === undefined ? 0n : BigInt(params.value), data: params.data || "0x", }; } async function signedFor(params, withWallet) { return (withWallet || signer).signTransaction(txFor(params)); } 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); 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)).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), ).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), ).not.toThrow(); }); test("rejects a swapped recipient", async () => { const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT, }); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( /approved recipient/, ); }); test("rejects an inflated value", async () => { const raw = await signedFor({ ...TX_PARAMS, value: "0x4563918244f40000", }); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow( /approved value/, ); }); test("rejects substituted call data", async () => { const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" }); expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).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)).toThrow( /different address/, ); }); test("rejects an unsigned transaction", () => { const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized; expect(() => verifySignedTx(unsigned, TX_PARAMS, signer.address), ).toThrow(/no valid signature/); }); test("rejects a missing or malformed payload", () => { expect(() => verifySignedTx(undefined, TX_PARAMS, signer.address), ).toThrow(/missing or malformed/); expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow( /missing or malformed/, ); expect(() => verifySignedTx("0xc0ffee", TX_PARAMS, signer.address), ).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); throw new Error("expected a rejection"); } catch (e) { expect(e.message).toMatch(/^[A-Z].*\.$/); } }); }); 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/); }); }); // 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); 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), ).toThrow(/approved recipient/); }); });