// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /// @dev Canonical SP1 verifier / SP1VerifierGateway ABI: verifyProof RETURNS /// NOTHING and REVERTS on an invalid proof. On chain 2800 the deployed /// SP1VerifierGateway is 0x9ca479C8c52C0EbB4599319a36a5a017BCC70628 and the /// SP1 Groth16 route is selected by the proof's leading 4-byte selector, /// exactly as AereRollupValidity / AereEVMValidity use it. interface ISp1Verifier { function verifyProof( bytes32 programVKey, bytes calldata publicValues, bytes calldata proof ) external view; } /** * @title AereZkEthLightClientV2 — succinct (zk) Ethereum beacon light client on AERE. * CORRECTED fork of AereZkEthLightClient 0x2a2b6D93…29E9d (see THE FIX below). * * @notice Advances a trust-minimized view of Ethereum finality on AERE (chain 2800) * by verifying an SP1 zkVM **Groth16 proof** that ONE Ethereum beacon * `LightClientUpdate` is valid against the currently-trusted sync committee. * The proof (guest `eth-lightclient-guest`, UNCHANGED — same ELF, same vkey * 0x00f03e1a…d191) checks, INSIDE the zkVM: * 1. the supplied 512 committee pubkeys hash (SSZ) to the trusted root; * 2. participation >= 2/3 of 512, and aggregates the participants (G1); * 3. the aggregate BLS12-381 signature over the SSZ signing root of the * attested header verifies (hash-to-G2 + pairing); * 4. the finalized header is committed in the attested state (Electra * finality Merkle branch, gindex 169); * 5. the delivered next sync committee is committed in the attested state * (next-committee branch, gindex 87); * 6. the attested slot is not behind the finalized slot. * * @dev ------------------------------------------------------------------ * THE FIX (vs V1 0x2a2b6D93…29E9d) — UNBOUND TRUST ANCHOR IN verify(). * * V1's stateful `processProof` was SAFE: it bound the proof's committed * `gvr` to the immutable `genesisValidatorsRoot` and its committed * `prevCommitteeRoot` to the stored `trustedCommitteeRoot` before advancing. * * V1's PUBLIC `verify()` view did NOT. It ran the Groth16 verify and * abi.decoded the public values with NO anchor binding at all. A proof is * only ever a statement of the form "SOME committee, whose root is * `prevCommitteeRoot`, signed this update" — the guest is honest, but it * cannot know WHICH committee is Ethereum's real one. So a prover could * generate a perfectly VALID proof over a sync committee of their OWN 512 * keys, self-sign the update, and V1's `verify()` would return the decoded * tuple without reverting. Any consumer treating `verify()` as a finality * oracle ("it didn't revert => this Ethereum header is final") could be * handed an arbitrary attacker-chosen `finalizedHeaderRoot`. * * V2 makes `verify()` apply the SAME anchor binds as `processProof`: * * `gvr == genesisValidatorsRoot` (IMMUTABLE anchor), and * * `prevCommitteeRoot == trustedCommitteeRoot` (this client's lineage), and * * `nextCommitteeRoot != 0`. * An unbound proof over a prover-chosen committee now REVERTS with * `CommitteeMismatch`. This mirrors the sibling AereZkQbftLightClient * 0xCaDA54FA…6488, whose `verify()` was always bound, and the same * anchor-binding fix shipped as AereOutboundVerifierV2 0x08b68bd5…a96F. * * WHAT V2 DELIBERATELY DOES NOT BIND IN verify(): the monotonic slot * advance (`newFinalizedSlot > finalizedSlot`). That is a property of THIS * client's head, not of the proof's trust anchor, and a stateless view has * no head to advance. Omitting it is intentional and matches * AereZkQbftLightClient.verify(). CONSUMER NOTE: `verify()` returning means * "this update was signed by the committee this client currently trusts" — * it does NOT mean the update is newer than the tracked head. A consumer * that needs recency must read `finalizedSlot()` / use `processProof`. * ------------------------------------------------------------------ * * @dev WHY THIS CONTRACT EXISTS AT ALL. The sibling `AereEthLightClient` verifies * the SAME update DIRECTLY on chain against AERE's EIP-2537 BLS12-381 * precompiles. That path is correct but its 512-pubkey committee-root * recomputation + BLS aggregation costs ~13.2M+ gas and the full update OOGs * at AERE's EIP-7825 2^24 (16,777,216) per-tx gas cap. Moving the heavy * verification into a succinct proof makes the on-chain step a single Groth16 * verify — well under the cap. * * @dev SECURITY MODEL — read before quoting. * * What is proven ON CHAIN here is the Groth16 proof + the public-value * binding (committee lineage, slot advance, gvr). What is proven OFF CHAIN * (inside the zkVM) is the sync-committee signature + SSZ branches. * * Security reduces to the honesty of >= 2/3 of Ethereum's 512-member sync * committee, exactly like every Altair light client. NOT full Ethereum * consensus, NOT a fraud/zk execution bridge. * * TRUST-MINIMIZED, NOT TRUSTLESS and NOT QUANTUM-SAFE: the inbound signature * scheme is BLS12-381 (classical, Ethereum's inherited crypto) and the proof * wrap is an SP1 Groth16 SNARK over BN254 (classical). Both are * quantum-vulnerable. This says nothing about AERE's own PQC work, and this * contract does NOT touch AERE consensus (still classical ECDSA QBFT). * * Bootstrap is weakly subjective: the constructor pins a trusted committee * root + finalized slot/header (a checkpoint). From there it self-advances, * one committee per accepted proof. * * COMMITTEE LINEAGE IS AN EXPIRING CREDENTIAL. `trustedCommitteeRoot` only * moves when someone submits a proof. If nobody advances it for > ~1 sync * period, this client's trusted committee falls behind Ethereum's live one * and `verify()` will (correctly) reject proofs for the current committee. * A consumer MUST check `finalizedSlot()` for recency; a bound `verify()` * is not a freshness guarantee. * * PROGRAM BINDING: PROGRAM_VKEY is the SP1 vkey of the exact guest ELF that * runs the update verification. Binding the vkey binds the exact program. * * PUBLIC-VALUES CONVENTION (exactly 192 bytes = 6 x 32) — IDENTICAL to V1, * because the guest is UNCHANGED: * abi.encode( * bytes32 gvr, // genesis validators root used for domain * bytes32 prevCommitteeRoot, // trusted signing-committee SSZ root * uint64 finalizedSlot, // new finalized slot * bytes32 finalizedHeaderRoot, // new finalized beacon header root * bytes32 nextCommitteeRoot, // delivered next sync-committee root * uint64 participation // participants (guest enforces >= 342) * ) * * IMMUTABILITY / TRUST: SP1_VERIFIER, PROGRAM_VKEY and genesisValidatorsRoot * are fixed at deploy. There is NO owner and NO admin: advancing is * permissionless. Soundness rests entirely on the SP1 Groth16 proof plus the * on-chain lineage/monotonicity checks. */ contract AereZkEthLightClientV2 { /// @notice SP1 verifier gateway (routes to the Groth16 verifier by selector). address public immutable SP1_VERIFIER; /// @notice SP1 vkey binding the exact eth-lightclient guest ELF. bytes32 public immutable PROGRAM_VKEY; /// @notice Ethereum genesis validators root (mainnet), bound in every proof's /// domain. A proof for a different network's gvr is rejected. bytes32 public immutable genesisValidatorsRoot; /// @notice The sync-committee root a valid next proof MUST have signed with. bytes32 public trustedCommitteeRoot; /// @notice The highest finalized beacon slot advanced so far. uint64 public finalizedSlot; /// @notice The finalized beacon block header root at `finalizedSlot`. bytes32 public finalizedHeaderRoot; /// @notice Number of proofs accepted (finality advances). uint64 public updateCount; event Bootstrapped(bytes32 committeeRoot, uint64 finalizedSlot, bytes32 finalizedHeaderRoot, bytes32 gvr); event FinalityAdvanced( uint64 indexed finalizedSlot, bytes32 finalizedHeaderRoot, bytes32 rotatedFromCommittee, bytes32 rotatedToCommittee, uint64 participation, address prover ); error InvalidProof(); error BadPublicValuesLength(uint256 got); error WrongGenesisValidatorsRoot(bytes32 inProof, bytes32 expected); error CommitteeMismatch(bytes32 inProof, bytes32 trusted); error StaleFinality(uint64 inProof, uint64 current); error ZeroNextCommittee(); error ZeroAddress(); error ZeroValue(); error VerifierHasNoCode(); constructor( address sp1Verifier, bytes32 programVKey, bytes32 gvr, bytes32 bootstrapCommitteeRoot, uint64 bootstrapFinalizedSlot, bytes32 bootstrapFinalizedHeaderRoot ) { if (sp1Verifier == address(0)) revert ZeroAddress(); // A code-less verifier would make verifyProof (which returns no data) succeed // silently, accepting every "proof". Mirrors the AereZkQbftLightClient / // AereOutboundVerifierV2 guard: removes a deploy-time footgun. if (sp1Verifier.code.length == 0) revert VerifierHasNoCode(); if (programVKey == bytes32(0) || gvr == bytes32(0) || bootstrapCommitteeRoot == bytes32(0)) { revert ZeroValue(); } SP1_VERIFIER = sp1Verifier; PROGRAM_VKEY = programVKey; genesisValidatorsRoot = gvr; trustedCommitteeRoot = bootstrapCommitteeRoot; finalizedSlot = bootstrapFinalizedSlot; finalizedHeaderRoot = bootstrapFinalizedHeaderRoot; emit Bootstrapped(bootstrapCommitteeRoot, bootstrapFinalizedSlot, bootstrapFinalizedHeaderRoot, gvr); } /// @dev The trust-anchor bind, applied IDENTICALLY by `processProof` and `verify`. /// This is the whole point of V2: there is exactly ONE place that decides /// whether a proof is about the committee/network this client trusts, and /// BOTH entry points go through it. function _bindToAnchor( bytes32 gvr, bytes32 prevCommitteeRoot, bytes32 nextCommitteeRoot ) internal view { if (gvr != genesisValidatorsRoot) revert WrongGenesisValidatorsRoot(gvr, genesisValidatorsRoot); if (prevCommitteeRoot != trustedCommitteeRoot) { revert CommitteeMismatch(prevCommitteeRoot, trustedCommitteeRoot); } if (nextCommitteeRoot == bytes32(0)) revert ZeroNextCommittee(); } /// @dev Verify the Groth16 proof through the gateway and decode the 192-byte /// public values. Reverts on a bad length or an invalid proof. function _verifyAndDecode(bytes calldata publicValues, bytes calldata proof) internal view returns ( bytes32 gvr, bytes32 prevCommitteeRoot, uint64 newFinalizedSlot, bytes32 newFinalizedHeaderRoot, bytes32 nextCommitteeRoot, uint64 participation ) { if (publicValues.length != 192) revert BadPublicValuesLength(publicValues.length); try ISp1Verifier(SP1_VERIFIER).verifyProof(PROGRAM_VKEY, publicValues, proof) { // verified — did not revert } catch { revert InvalidProof(); } (gvr, prevCommitteeRoot, newFinalizedSlot, newFinalizedHeaderRoot, nextCommitteeRoot, participation) = abi.decode(publicValues, (bytes32, bytes32, uint64, bytes32, bytes32, uint64)); } /// @notice Verify a zk proof of one Ethereum LightClientUpdate and, on success, /// advance finality + rotate the trusted committee. Permissionless. /// @param publicValues abi.encode(gvr, prevCommitteeRoot, finalizedSlot, /// finalizedHeaderRoot, nextCommitteeRoot, participation), 192 bytes. /// @param proof SP1 Groth16 proof bytes. function processProof(bytes calldata publicValues, bytes calldata proof) external { ( bytes32 gvr, bytes32 prevCommitteeRoot, uint64 newFinalizedSlot, bytes32 newFinalizedHeaderRoot, bytes32 nextCommitteeRoot, uint64 participation ) = _verifyAndDecode(publicValues, proof); // 1) bind the proof to THIS client's trust anchor (gvr + committee lineage). _bindToAnchor(gvr, prevCommitteeRoot, nextCommitteeRoot); // 2) stateful-only: the head must strictly advance. if (newFinalizedSlot <= finalizedSlot) revert StaleFinality(newFinalizedSlot, finalizedSlot); // 3) advance finality + rotate the trusted committee to the delivered next. bytes32 from = trustedCommitteeRoot; finalizedSlot = newFinalizedSlot; finalizedHeaderRoot = newFinalizedHeaderRoot; trustedCommitteeRoot = nextCommitteeRoot; unchecked { updateCount += 1; } emit FinalityAdvanced( newFinalizedSlot, newFinalizedHeaderRoot, from, nextCommitteeRoot, participation, msg.sender ); } /// @notice Stateless verification, BOUND TO THIS CLIENT'S TRUST ANCHOR (the V2 fix). /// Reverts if the proof is invalid for PROGRAM_VKEY, if the committed gvr is /// not this client's immutable `genesisValidatorsRoot`, or if the committed /// `prevCommitteeRoot` is not the committee this client currently trusts. /// Otherwise returns the decoded fields. Records nothing. /// @dev NO monotonicity constraint (a stateless view has no head to advance) — /// see the contract-level note. Returning means "signed by the committee this /// client trusts", NOT "newer than the tracked head". function verify(bytes calldata publicValues, bytes calldata proof) external view returns ( bytes32 gvr, bytes32 prevCommitteeRoot, uint64 newFinalizedSlot, bytes32 newFinalizedHeaderRoot, bytes32 nextCommitteeRoot, uint64 participation ) { (gvr, prevCommitteeRoot, newFinalizedSlot, newFinalizedHeaderRoot, nextCommitteeRoot, participation) = _verifyAndDecode(publicValues, proof); // THE V2 FIX: the same anchor bind processProof applies. V1 omitted this, // so verify() would return true for a proof over a prover-chosen committee. _bindToAnchor(gvr, prevCommitteeRoot, nextCommitteeRoot); } }