aere-contracts/test/zk-eth-lightclient-anchor-fix-2026-07-17.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

312 lines
15 KiB
JavaScript

// 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");
});
});
});