const { expect } = require("chai"); const { ethers } = require("hardhat"); const H = require("./helpers"); // ----------------------------------------------------------------------------- // AereEthLightClient — verified against AERE's LIVE EIP-2537 BLS12-381 precompiles // under the Prague hardfork (real crypto, NOT a mock). SSZ roots and Merkle branches // are cross-checked between the Solidity contract and an independent JS implementation. // ----------------------------------------------------------------------------- // A light-client update verifies ~512 pubkeys + BLS aggregation on precompiles; // measured ~32M gas (rotation ~2x). AERE's block gas is effectively unlimited; the // test just needs an explicit limit above hardhat's ~30M auto-send default. const GAS = { gasLimit: 400_000_000 }; const GVR = "0x" + "11".repeat(32); // synthetic genesis_validators_root const FORK_VERSION = "0x02000000"; // synthetic fork version (Bellatrix-shaped) const DOMAIN_SYNC = "0x07000000"; // Generalized indices (consensus-spec): finalized_checkpoint.root=105 (depth6), // next_sync_committee=55 (depth5). const GI_FINALIZED = 105; const GI_NEXT = 55; function beaconHeaderStruct(h) { return { slot: h.slot, proposerIndex: h.proposerIndex, parentRoot: H.hex(h.parentRoot), stateRoot: H.hex(h.stateRoot), bodyRoot: H.hex(h.bodyRoot), }; } // Build a complete, internally-consistent light-client update. // opts: { committee, finalizedSlot, attestedSlot, participants, nextCommittee } function buildUpdate(committee, opts) { const finalizedHeader = { slot: opts.finalizedSlot, proposerIndex: 7, parentRoot: Buffer.from("aa".repeat(32), "hex"), stateRoot: Buffer.from("bb".repeat(32), "hex"), bodyRoot: opts.finalizedBodyRoot || Buffer.from("cc".repeat(32), "hex"), }; const finalizedRoot = H.beaconHeaderRoot(finalizedHeader); // Assign leaves in the attested state tree (depth 6 covers gindex 55 and 105). const assign = {}; assign[GI_FINALIZED] = finalizedRoot; if (opts.nextCommittee) assign[GI_NEXT] = opts.nextCommittee.root; const tree = H.buildTree(6, assign); const attestedHeader = { slot: opts.attestedSlot, proposerIndex: 9, parentRoot: Buffer.from("dd".repeat(32), "hex"), stateRoot: tree.root, // commits both finality and (optionally) next committee bodyRoot: Buffer.from("ee".repeat(32), "hex"), }; const attestedRoot = H.beaconHeaderRoot(attestedHeader); const domain = H.computeDomain( Buffer.from(DOMAIN_SYNC.slice(2), "hex"), Buffer.from(FORK_VERSION.slice(2), "hex"), Buffer.from(GVR.slice(2), "hex") ); const signingRoot = H.shaPair(attestedRoot, domain); const { bits, aggSigUncompressed } = H.signSubset(committee, signingRoot, opts.participants); const finalityBranch = tree.branch(GI_FINALIZED).map(H.hex); const nextBranch = opts.nextCommittee ? tree.branch(GI_NEXT).map(H.hex) : []; return { attestedHeader: beaconHeaderStruct(attestedHeader), finalizedHeader: beaconHeaderStruct(finalizedHeader), finalityBranch, participationBits: H.hex(bits), signature: H.hex(aggSigUncompressed), pubkeys: H.hex(H.cat(...committee.uncompressed)), aggregatePubkey: H.hex(committee.aggUncompressed), signatureForkVersion: FORK_VERSION, signedByNextCommittee: opts.signedByNextCommittee || false, hasNextCommittee: !!opts.nextCommittee, nextPubkeys: opts.nextCommittee ? H.hex(H.cat(...opts.nextCommittee.uncompressed)) : "0x", nextAggregatePubkey: opts.nextCommittee ? H.hex(opts.nextCommittee.aggUncompressed) : "0x", nextSyncCommitteeBranch: nextBranch, _finalizedRoot: finalizedRoot, _signingRoot: signingRoot, _attestedRoot: attestedRoot, _domain: domain, }; } describe("AereEthLightClient (real EIP-2537 BLS, Prague)", function () { let committee, harness, bootstrapFinalized; before(async function () { this.timeout(300000); committee = H.makeCommittee("aere-eth-lc-committee-1"); }); beforeEach(async function () { bootstrapFinalized = { slot: 1000, proposerIndex: 1, parentRoot: "0x" + "00".repeat(32), stateRoot: "0x" + "01".repeat(32), bodyRoot: "0x" + "02".repeat(32), }; const F = await ethers.getContractFactory("LightClientHarness"); harness = await F.deploy(H.hex(H.DST), GVR, H.hex(committee.root), bootstrapFinalized); await harness.waitForDeployment(); }); describe("cross-checks: Solidity vs independent JS reference", function () { it("computes the SyncCommittee SSZ root identically", async function () { const onchain = await harness.h_committeeRoot( H.hex(H.cat(...committee.uncompressed)), H.hex(committee.aggUncompressed) ); expect(onchain).to.equal(H.hex(committee.root)); }); it("compresses a G1 pubkey identically to noble", async function () { const onchain = await harness.h_compress(H.hex(committee.uncompressed[0])); expect(onchain).to.equal(H.hex(committee.compressed[0])); }); it("computes the sync-committee domain identically", async function () { const onchain = await harness.h_domain(FORK_VERSION); const expected = H.computeDomain( Buffer.from(DOMAIN_SYNC.slice(2), "hex"), Buffer.from(FORK_VERSION.slice(2), "hex"), Buffer.from(GVR.slice(2), "hex") ); expect(onchain).to.equal(H.hex(expected)); }); it("hashes a message to G2 identically to noble hashToCurve", async function () { const msg = Buffer.from("interop-hash-to-curve-check", "ascii"); const onchain = await harness.h_hashToG2(H.hex(msg)); const expected = H.encG2(H.bls.G2.hashToCurve(msg)); expect(onchain.toLowerCase()).to.equal(H.hex(expected).toLowerCase()); }); }); describe("processUpdate — happy path", function () { it("accepts a valid full-participation update and advances finality", async function () { this.timeout(300000); const participants = [...Array(H.COMMITTEE_SIZE).keys()]; const u = buildUpdate(committee, { finalizedSlot: 2000, attestedSlot: 2032, participants, }); await expect(harness.processUpdate(u, GAS)).to.emit(harness, "FinalityUpdated"); expect(await harness.finalizedSlot()).to.equal(2000n); expect(await harness.finalizedHeaderRoot()).to.equal(H.hex(u._finalizedRoot)); }); it("accepts exactly the 2/3 participation threshold (342 of 512)", async function () { this.timeout(300000); const participants = [...Array(342).keys()]; const u = buildUpdate(committee, { finalizedSlot: 2100, attestedSlot: 2132, participants }); await expect(harness.processUpdate(u, GAS)).to.emit(harness, "FinalityUpdated"); expect(await harness.finalizedSlot()).to.equal(2100n); }); }); describe("processUpdate — rejections", function () { it("rejects below-threshold participation (341 of 512)", async function () { this.timeout(300000); const participants = [...Array(341).keys()]; const u = buildUpdate(committee, { finalizedSlot: 2200, attestedSlot: 2232, participants }); await expect(harness.processUpdate(u, GAS)).to.be.revertedWith("LC:insufficient participation"); }); it("rejects a forged signature (valid participants, tampered signature)", async function () { this.timeout(300000); const participants = [...Array(400).keys()]; const u = buildUpdate(committee, { finalizedSlot: 2300, attestedSlot: 2332, participants }); // Flip the signature to a different valid G2 point (sig of a different message). const other = H.signSubset(committee, Buffer.from("99".repeat(32), "hex"), participants); u.signature = H.hex(other.aggSigUncompressed); await expect(harness.processUpdate(u, GAS)).to.be.revertedWith("LC:bad signature"); }); it("rejects a committee whose pubkeys don't hash to the committed root", async function () { this.timeout(300000); const wrong = H.makeCommittee("a-different-committee"); const participants = [...Array(400).keys()]; const u = buildUpdate(committee, { finalizedSlot: 2400, attestedSlot: 2432, participants }); // swap in the wrong committee's pubkeys (signature no longer matters; root check first) u.pubkeys = H.hex(H.cat(...wrong.uncompressed)); u.aggregatePubkey = H.hex(wrong.aggUncompressed); await expect(harness.processUpdate(u, GAS)).to.be.revertedWith("LC:committee mismatch"); }); it("rejects a tampered finality branch", async function () { this.timeout(300000); const participants = [...Array(400).keys()]; const u = buildUpdate(committee, { finalizedSlot: 2500, attestedSlot: 2532, participants }); const b = u.finalityBranch.slice(); b[0] = "0x" + "de".repeat(32); u.finalityBranch = b; await expect(harness.processUpdate(u, GAS)).to.be.revertedWith("LC:bad finality branch"); }); it("rejects stale finality (slot not advancing)", async function () { this.timeout(300000); const participants = [...Array(400).keys()]; const u = buildUpdate(committee, { finalizedSlot: 900, attestedSlot: 2000, participants }); await expect(harness.processUpdate(u, GAS)).to.be.revertedWith("LC:stale finality"); }); }); describe("committee rotation", function () { it("stores a next committee, then rotates to it on a next-committee-signed update", async function () { this.timeout(300000); const next = H.makeCommittee("aere-eth-lc-committee-2"); const participants = [...Array(400).keys()]; // Update 1: signed by current committee, carries next_sync_committee. const u1 = buildUpdate(committee, { finalizedSlot: 3000, attestedSlot: 3032, participants, nextCommittee: next, }); await expect(harness.processUpdate(u1, GAS)).to.emit(harness, "NextCommitteeStored"); expect(await harness.nextSyncCommitteeRoot()).to.equal(H.hex(next.root)); // Update 2: signed by the NEXT committee -> current rotates to next. const u2 = buildUpdate(next, { finalizedSlot: 4000, attestedSlot: 4032, participants, signedByNextCommittee: true, }); await expect(harness.processUpdate(u2, GAS)).to.emit(harness, "CommitteeRotated"); expect(await harness.currentSyncCommitteeRoot()).to.equal(H.hex(next.root)); expect(await harness.nextSyncCommitteeRoot()).to.equal("0x" + "00".repeat(32)); expect(await harness.finalizedSlot()).to.equal(4000n); }); }); });