// AereZkEthLightClientV2 — regression tests for the UNBOUND TRUST ANCHOR in V1's verify(). // // FINDING (2026-07-17 sweep, code-read): AereZkEthLightClient 0x2a2b6D93…29E9d binds the // committed gvr + prevCommitteeRoot to its trusted state in `processProof` (SAFE), but its // public `verify()` view runs the Groth16 verify and abi.decodes with NO binds at all. A proof // is only ever "SOME committee whose root is prevCommitteeRoot signed this update" — the guest // is honest but cannot know which committee is Ethereum's REAL one. So an attacker generates a // genuinely VALID proof over a sync committee of their OWN keys, self-signs an arbitrary // finalized header, and V1's verify() returns it without reverting. Any consumer using verify() // as a finality oracle is handed an attacker-chosen Ethereum head. // // Each attack test below is PAIRED with a control on the REAL V1 contract showing the exact // same call SUCCEEDS on V1 — i.e. the bug was real, and V2 is what closes it. // // The SP1 verifier is MOCKED (MockSp1Verifier: verifyProof reverts unless the exact // (vkey, publicValues, proof) triple was pre-registered — mirroring the real gateway, which // reverts on an invalid proof). That is the CORRECT model of this attack: the attacker's proof // is CRYPTOGRAPHICALLY VALID (the guest ran honestly over the attacker's own committee), so the // gateway accepts it. The bug is not a broken proof — it is a proof about the wrong anchor. // This is exactly how the sibling AereOutboundVerifierV2 finding was proven, where a REAL // Groth16 proof over a forged validator set was accepted by the REAL live gateway. const { expect } = require("chai"); const { ethers } = require("hardhat"); const VKEY = "0x" + "ab".repeat(32); const PROOF = "0x1234567890"; // opaque proof bytes; the mock keys acceptance on the triple // EXACTLY the guest's public-values order: // abi.encode(bytes32 gvr, bytes32 prevCommitteeRoot, uint64 finalizedSlot, // bytes32 finalizedHeaderRoot, bytes32 nextCommitteeRoot, uint64 participation) const PV_TYPES = ["bytes32", "bytes32", "uint64", "bytes32", "bytes32", "uint64"]; function encodePV({ gvr, prevCommitteeRoot, finalizedSlot, finalizedHeaderRoot, nextCommitteeRoot, participation }) { return ethers.AbiCoder.defaultAbiCoder().encode(PV_TYPES, [ gvr, prevCommitteeRoot, finalizedSlot, finalizedHeaderRoot, nextCommitteeRoot, participation, ]); } function marker(vkey, pv, proof) { return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof])); } // REAL live constants from AereZkEthLightClient 0x2a2b6D93…29E9d (read on chain 2800, // 2026-07-17): the mainnet genesis validators root, and the committee/slot the live client // had ALREADY rotated to after its one real proof (updateCount == 1). const GVR = "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95"; const TRUSTED_COMMITTEE = "0x68874d30c32a0ed42a8d59c93ff42a1e8b93f038dc0286c6cb8f25de72ae6c96"; // period 1802 const BOOT_SLOT = 14753792n; const BOOT_HEADER = "0x0d62648b76db75112a550630e3c9087ff7746e76e8774c8a61ed3f3a410b57d7"; // The attacker's OWN 512-key sync committee: its SSZ root is whatever they choose. This is the // unbound value V1's verify() never checked. const ATTACKER_COMMITTEE = "0x" + "de".repeat(32); const ATTACKER_HEADER = "0x" + "ba".repeat(32); // arbitrary "finalized" Ethereum head const NEXT_COMMITTEE = "0x" + "77".repeat(32); describe("AereZkEthLightClientV2 — unbound-verify() anchor fix (2026-07-17)", function () { let mock, v1, v2; function pv(over = {}) { return encodePV({ gvr: GVR, prevCommitteeRoot: TRUSTED_COMMITTEE, finalizedSlot: 14761984n, finalizedHeaderRoot: "0x" + "22".repeat(32), nextCommitteeRoot: NEXT_COMMITTEE, participation: 512n, ...over, }); } async function accept(publicValues, proof = PROOF) { await mock.setValid(marker(VKEY, publicValues, proof), true); } beforeEach(async function () { mock = await (await ethers.getContractFactory("MockSp1Verifier")).deploy(); await mock.waitForDeployment(); const args = [await mock.getAddress(), VKEY, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER]; // The REAL V1 contract, deployed with the SAME args — the control. v1 = await (await ethers.getContractFactory("AereZkEthLightClient")).deploy(...args); await v1.waitForDeployment(); v2 = await (await ethers.getContractFactory("AereZkEthLightClientV2")).deploy(...args); await v2.waitForDeployment(); }); /* ------------------------ bootstrap / lineage preservation ------------------------ */ it("bootstraps immutably from the LIVE V1 lineage (vkey, gvr, rotated committee, head)", async function () { expect(await v2.SP1_VERIFIER()).to.equal(await mock.getAddress()); expect(await v2.PROGRAM_VKEY()).to.equal(VKEY); expect(await v2.genesisValidatorsRoot()).to.equal(GVR); expect(await v2.trustedCommitteeRoot()).to.equal(TRUSTED_COMMITTEE); expect(await v2.finalizedSlot()).to.equal(BOOT_SLOT); expect(await v2.finalizedHeaderRoot()).to.equal(BOOT_HEADER); expect(await v2.updateCount()).to.equal(0n); }); it("the guest public-values encoding is exactly 192 bytes (6 x 32) — guest UNCHANGED", async function () { expect(ethers.dataLength(pv())).to.equal(192); }); /* ================================================================================= THE ATTACK: a VALID proof over a PROVER-CHOSEN sync committee. ================================================================================= */ describe("ATTACK — prover-chosen sync committee handed to verify() as an oracle", function () { // The attacker's proof: genuinely valid for the vkey (the guest honestly verified a // 512/512 BLS aggregate over the ATTACKER'S committee), committing an arbitrary // Ethereum finalized header. Only `prevCommitteeRoot` gives it away — and V1 never looked. const attackPv = () => encodePV({ gvr: GVR, prevCommitteeRoot: ATTACKER_COMMITTEE, // <-- NOT the committee this client trusts finalizedSlot: 99999999n, finalizedHeaderRoot: ATTACKER_HEADER, nextCommitteeRoot: NEXT_COMMITTEE, participation: 512n, }); it("PAIRED CONTROL: V1.verify() ACCEPTS it and returns the attacker's Ethereum head", async function () { const p = attackPv(); await accept(p); // V1 does not revert — it hands back the attacker's chosen finalized header root. const out = await v1.verify(p, PROOF); expect(out[1]).to.equal(ATTACKER_COMMITTEE); // prevCommitteeRoot: never checked expect(out[3]).to.equal(ATTACKER_HEADER); // finalizedHeaderRoot: attacker-chosen expect(out[2]).to.equal(99999999n); // ...even though the client's REAL trusted committee is something else entirely. expect(await v1.trustedCommitteeRoot()).to.equal(TRUSTED_COMMITTEE); expect(await v1.trustedCommitteeRoot()).to.not.equal(ATTACKER_COMMITTEE); }); it("V2.verify() REVERTS with CommitteeMismatch — the attack is closed", async function () { const p = attackPv(); await accept(p); await expect(v2.verify(p, PROOF)) .to.be.revertedWithCustomError(v2, "CommitteeMismatch") .withArgs(ATTACKER_COMMITTEE, TRUSTED_COMMITTEE); }); it("V1.processProof() ALREADY rejected it (the stateful path was never broken)", async function () { const p = attackPv(); await accept(p); await expect(v1.processProof(p, PROOF)) .to.be.revertedWithCustomError(v1, "CommitteeMismatch") .withArgs(ATTACKER_COMMITTEE, TRUSTED_COMMITTEE); }); }); /* ================================================================================= THE SAME BUG VIA THE OTHER UNBOUND FIELD: a wrong-network gvr. ================================================================================= */ describe("ATTACK — wrong-network genesis validators root through verify()", function () { const WRONG_GVR = "0x" + "99".repeat(32); // e.g. a testnet / attacker-chosen domain const attackPv = () => pv({ gvr: WRONG_GVR, finalizedHeaderRoot: ATTACKER_HEADER }); it("PAIRED CONTROL: V1.verify() ACCEPTS a proof for a different network's gvr", async function () { const p = attackPv(); await accept(p); const out = await v1.verify(p, PROOF); expect(out[0]).to.equal(WRONG_GVR); expect(out[3]).to.equal(ATTACKER_HEADER); expect(await v1.genesisValidatorsRoot()).to.not.equal(WRONG_GVR); }); it("V2.verify() REVERTS with WrongGenesisValidatorsRoot", async function () { const p = attackPv(); await accept(p); await expect(v2.verify(p, PROOF)) .to.be.revertedWithCustomError(v2, "WrongGenesisValidatorsRoot") .withArgs(WRONG_GVR, GVR); }); }); describe("ATTACK — zero next committee through verify()", function () { const attackPv = () => pv({ nextCommitteeRoot: ethers.ZeroHash }); it("PAIRED CONTROL: V1.verify() ACCEPTS a zero next-committee proof", async function () { const p = attackPv(); await accept(p); const out = await v1.verify(p, PROOF); expect(out[4]).to.equal(ethers.ZeroHash); }); it("V2.verify() REVERTS with ZeroNextCommittee", async function () { const p = attackPv(); await accept(p); await expect(v2.verify(p, PROOF)).to.be.revertedWithCustomError(v2, "ZeroNextCommittee"); }); }); /* ------------------------------ V2 still accepts the REAL thing ------------------ */ describe("V2 does not over-reject — the honest path still works", function () { it("verify() ACCEPTS a proof signed by the committee this client trusts", async function () { const p = pv(); await accept(p); const out = await v2.verify(p, PROOF); expect(out[0]).to.equal(GVR); expect(out[1]).to.equal(TRUSTED_COMMITTEE); expect(out[2]).to.equal(14761984n); expect(out[4]).to.equal(NEXT_COMMITTEE); expect(out[5]).to.equal(512n); }); it("verify() records nothing (pure view: head + updateCount unmoved)", async function () { const p = pv(); await accept(p); await v2.verify(p, PROOF); expect(await v2.finalizedSlot()).to.equal(BOOT_SLOT); expect(await v2.updateCount()).to.equal(0n); }); it("processProof() ACCEPTS the honest proof, advances the head and rotates the committee", async function () { const p = pv(); await accept(p); await expect(v2.processProof(p, PROOF)).to.emit(v2, "FinalityAdvanced"); expect(await v2.finalizedSlot()).to.equal(14761984n); expect(await v2.finalizedHeaderRoot()).to.equal("0x" + "22".repeat(32)); expect(await v2.trustedCommitteeRoot()).to.equal(NEXT_COMMITTEE); // rotated expect(await v2.updateCount()).to.equal(1n); }); it("verify() tracks the ROTATED committee after processProof (lineage follows the head)", async function () { const p = pv(); await accept(p); await v2.processProof(p, PROOF); // the once-honest proof is now bound to a committee that has been rotated away from await expect(v2.verify(p, PROOF)) .to.be.revertedWithCustomError(v2, "CommitteeMismatch") .withArgs(TRUSTED_COMMITTEE, NEXT_COMMITTEE); // ...and a proof signed by the NEW trusted committee verifies. const next = encodePV({ gvr: GVR, prevCommitteeRoot: NEXT_COMMITTEE, finalizedSlot: 14770176n, finalizedHeaderRoot: "0x" + "44".repeat(32), nextCommitteeRoot: "0x" + "55".repeat(32), participation: 500n, }); await accept(next); const out = await v2.verify(next, PROOF); expect(out[1]).to.equal(NEXT_COMMITTEE); }); }); /* ------------------------------ proof integrity still enforced ------------------- */ describe("proof + encoding integrity (unchanged from V1)", function () { it("an unregistered (invalid) proof reverts InvalidProof in processProof", async function () { await expect(v2.processProof(pv(), PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof"); }); it("verify() reverts when the gateway rejects the proof", async function () { // the mock reverts MockInvalidProof; verify() does not wrap it (view, mirrors V1/QBFT) await expect(v2.verify(pv(), PROOF)).to.be.reverted; }); it("tampered publicValues break the (vkey, pv, proof) triple => rejected", async function () { const p = pv(); await accept(p); const tampered = pv({ finalizedHeaderRoot: "0x" + "66".repeat(32) }); await expect(v2.processProof(tampered, PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof"); }); it("a wrong-length publicValues reverts BadPublicValuesLength", async function () { await expect(v2.processProof("0x1234", PROOF)) .to.be.revertedWithCustomError(v2, "BadPublicValuesLength") .withArgs(2); await expect(v2.verify("0x1234", PROOF)) .to.be.revertedWithCustomError(v2, "BadPublicValuesLength") .withArgs(2); }); it("processProof still enforces the monotonic head (StaleFinality)", async function () { const stale = pv({ prevCommitteeRoot: TRUSTED_COMMITTEE, finalizedSlot: BOOT_SLOT }); await accept(stale); await expect(v2.processProof(stale, PROOF)) .to.be.revertedWithCustomError(v2, "StaleFinality") .withArgs(BOOT_SLOT, BOOT_SLOT); }); }); /* ------------------------------ constructor guards ------------------------------- */ describe("constructor guards", function () { it("rejects a code-less SP1 verifier (V1 accepted one — silent-accept footgun)", async function () { const F = await ethers.getContractFactory("AereZkEthLightClientV2"); await expect( F.deploy("0x000000000000000000000000000000000000dEaD", VKEY, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER) ).to.be.revertedWithCustomError(F, "VerifierHasNoCode"); }); it("rejects a zero vkey / zero gvr / zero bootstrap committee", async function () { const F = await ethers.getContractFactory("AereZkEthLightClientV2"); const m = await mock.getAddress(); await expect(F.deploy(m, ethers.ZeroHash, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER)) .to.be.revertedWithCustomError(F, "ZeroValue"); await expect(F.deploy(m, VKEY, ethers.ZeroHash, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER)) .to.be.revertedWithCustomError(F, "ZeroValue"); await expect(F.deploy(m, VKEY, GVR, ethers.ZeroHash, BOOT_SLOT, BOOT_HEADER)) .to.be.revertedWithCustomError(F, "ZeroValue"); }); it("rejects the zero address", async function () { const F = await ethers.getContractFactory("AereZkEthLightClientV2"); await expect(F.deploy(ethers.ZeroAddress, VKEY, GVR, TRUSTED_COMMITTEE, BOOT_SLOT, BOOT_HEADER)) .to.be.revertedWithCustomError(F, "ZeroAddress"); }); }); });