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