// 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 AereZkEthLightClient / AereRollupValidity / AereEVMValidity use it. interface ISp1Verifier { function verifyProof( bytes32 programVKey, bytes calldata publicValues, bytes calldata proof ) external view; } /** * @title AereZkQbftLightClient — succinct (zk) light client of AERE's OWN QBFT finality. * * @notice The OUTBOUND direction of AERE's zk light-client stack: it lets ANY chain (or * any verifier) advance a trust-minimized view of AERE (chain 2800) finality by * checking an SP1 zkVM **Groth16 proof** that ONE AERE block header is FINAL * under Hyperledger Besu QBFT. The proof (guest `qbft-lightclient-guest`, sharing * `qbft_finality::verify_finality` verbatim with the real-header test) checks, * INSIDE the zkVM: * 1. decode the header's QBFT `extraData` exactly as Besu `QbftExtraDataCodec`; * 2. the header's stored validator set equals the known (trusted) set; * 3. recompute the on-chain block hash (`EXCLUDE_COMMIT_SEALS_AND_ROUND_NUMBER`) * and the committed-seal digest (`EXCLUDE_COMMIT_SEALS`); * 4. ecrecover every 65-byte committed seal over the digest and keep those that * map to a DISTINCT known validator; * 5. assert the distinct count reaches the QBFT quorum `ceil(2N/3)`. * * This contract does the CHEAP part on chain: it verifies the ~300k-gas Groth16 * proof through the live SP1 gateway, binds the proof's public values to its own * trusted validator-set anchor, enforces a STRICTLY ADVANCING block number, and on * success records the newly-final AERE block (number + on-chain hash). Permissionless. * * @dev WHY THIS EXISTS. The sibling `AereZkEthLightClient` is the INBOUND direction * (AERE tracking Ethereum's sync-committee finality). This is its mirror: it makes * AERE's QBFT finality independently, succinctly checkable by an external verifier, * so a bridge OUT of AERE rests on a proof of the committed-seal quorum instead of a * multisig attestation. The hard verification (RLP layout, dual Besu hash * pre-images, secp256k1 committed-seal recovery, distinct-quorum counting) is proven * off-chain in the zkVM and against REAL chain-2800 headers before proving; the * on-chain step is one Groth16 verify plus the lineage/monotonicity binding below. * * @dev SECURITY MODEL — read before quoting. * * On chain here we verify the Groth16 proof + bind its public values (validator-set * anchor, strict block-number advance, finalized flag). Off chain (in the zkVM) we * prove the committed-seal quorum. Security reduces to the honesty of `>= 2f+1` of * the trusted QBFT validator set — exactly what QBFT finality means, no more. * * TRUST-MINIMIZED, NOT TRUSTLESS, NOT QUANTUM-SAFE. AERE mainnet QBFT commits on * classical secp256k1 ECDSA, and the proof wrap is an SP1 Groth16 SNARK over BN254 * (classical). Both are quantum-vulnerable. `falconVerified` is committed `false` by * the guest today and is recorded, never required — it becomes meaningful only once * mainnet consensus VERIFIABLY blocks on a Falcon-512 quorum certificate. * * With AERE's live N=7 validator set the quorum is 5 (2f+1, f=2). All 7 are * Foundation-operated, so the honest-quorum premise is an OPERATOR assumption, not * an economic one; the light client is only as decentralized as that set. * * VALIDATOR-SET ANCHOR IS IMMUTABLE. `trustedValidatorSetRoot` is pinned at deploy * (= `keccak256(addr_0 ‖ addr_1 ‖ …)` over the set in stored order, matching the * guest's `validator_set_root`). The guest proves finality against a FIXED known * set, so this contract binds to that set and does NOT track validator-set changes. * A validator rotation therefore requires a fresh anchor (new deployment / * checkpoint). Stated honestly; rotation is a documented follow-up, not a silent gap. * THIS IS NOT THEORETICAL: the first deployment (0xc9A2DCae…B1c2) anchored the then-N=3 * set, the live set grew to N=7 at block 9,312,565, and that client was thereby frozen * out of verifying current finality — it was replaced by 0xCaDA54FA…6488 on 2026-07-16 * (see zk-light-client/QBFT-LIGHTCLIENT-7SET-2026-07-16.md). Treat the anchor as an * expiring credential: a consumer MUST check that trustedValidatorSetRoot still equals * keccak256 over the live qbft_getValidatorsByBlockNumber set before trusting this * client as a finality oracle. * * WEAKLY-SUBJECTIVE BOOTSTRAP: the constructor pins the validator-set anchor and an * initial finalized block number; from there the client self-advances by proof. * * PROGRAM BINDING: `PROGRAM_VKEY` is the SP1 vkey of the exact guest ELF. Binding * the vkey binds the exact verification program. * * PUBLIC-VALUES CONVENTION (exactly 192 bytes = 6 x 32, matching the guest's * `PublicValues` `abi.encode`): * abi.encode( * bytes32 blockHash, // recomputed on-chain block hash * uint64 blockNumber, // the block's height * bytes32 committedSealHash, // digest the seals were recovered over * bytes32 validatorSetRoot, // commitment to the validator set used as anchor * bool finalized, // guest asserts true; re-checked here * bool falconVerified // reserved PQC slot; false on mainnet today * ) * * IMMUTABILITY / TRUST: SP1_VERIFIER, PROGRAM_VKEY and trustedValidatorSetRoot are * fixed at deploy. There is NO owner and NO admin: advancing is permissionless. * Soundness rests on the SP1 Groth16 proof plus the on-chain anchor/monotonicity checks. */ contract AereZkQbftLightClient { /// @notice SP1 verifier gateway (routes to the Groth16 verifier by selector). address public immutable SP1_VERIFIER; /// @notice SP1 vkey binding the exact qbft-lightclient guest ELF. bytes32 public immutable PROGRAM_VKEY; /// @notice Commitment to the trusted AERE validator set: keccak256 over the /// addresses in stored order. Every accepted proof must be about THIS set. bytes32 public immutable trustedValidatorSetRoot; /// @notice The highest QBFT-final AERE block number advanced so far. uint64 public finalizedNumber; /// @notice The on-chain block hash of the block at `finalizedNumber`. bytes32 public finalizedBlockHash; /// @notice The committed-seal digest recorded for the latest advance. bytes32 public lastCommittedSealHash; /// @notice Whether the latest accepted proof carried a verified Falcon certificate. /// False on mainnet today (classical ECDSA); recorded for transparency. bool public lastFalconVerified; /// @notice Number of proofs accepted (finality advances). uint64 public updateCount; event Bootstrapped(bytes32 validatorSetRoot, uint64 finalizedNumber, bytes32 finalizedBlockHash); event FinalityAdvanced( uint64 indexed blockNumber, bytes32 blockHash, bytes32 committedSealHash, bool falconVerified, address prover ); error InvalidProof(); error BadPublicValuesLength(uint256 got); error ValidatorSetMismatch(bytes32 inProof, bytes32 trusted); error NotFinalized(); error StaleFinality(uint64 inProof, uint64 current); error ZeroBlockHash(); error ZeroAddress(); error ZeroValue(); error VerifierHasNoCode(); constructor( address sp1Verifier, bytes32 programVKey, bytes32 validatorSetRoot, uint64 bootstrapFinalizedNumber, bytes32 bootstrapFinalizedBlockHash ) { if (sp1Verifier == address(0)) revert ZeroAddress(); // A code-less verifier would make verifyProof (which returns no data) succeed silently, // accepting every "proof". Attacker cannot reach this (no control of constructor args), but // it removes a deploy-time footgun; the deployed value must be the live SP1 gateway. if (sp1Verifier.code.length == 0) revert VerifierHasNoCode(); if (programVKey == bytes32(0) || validatorSetRoot == bytes32(0)) revert ZeroValue(); SP1_VERIFIER = sp1Verifier; PROGRAM_VKEY = programVKey; trustedValidatorSetRoot = validatorSetRoot; finalizedNumber = bootstrapFinalizedNumber; finalizedBlockHash = bootstrapFinalizedBlockHash; emit Bootstrapped(validatorSetRoot, bootstrapFinalizedNumber, bootstrapFinalizedBlockHash); } /// @notice Verify a zk proof that one AERE block is QBFT-final and, on success, /// advance the tracked finalized head. Permissionless. /// @param publicValues abi.encode(blockHash, blockNumber, committedSealHash, /// validatorSetRoot, finalized, falconVerified), 192 bytes. /// @param proof SP1 Groth16 proof bytes. function processProof(bytes calldata publicValues, bytes calldata proof) external { if (publicValues.length != 192) revert BadPublicValuesLength(publicValues.length); // 1) zk proof: reverts if the proof is not valid for PROGRAM_VKEY. try ISp1Verifier(SP1_VERIFIER).verifyProof(PROGRAM_VKEY, publicValues, proof) { // verified — did not revert } catch { revert InvalidProof(); } ( bytes32 blockHash, uint64 blockNumber, bytes32 committedSealHash, bytes32 validatorSetRoot, bool finalized, bool falconVerified ) = abi.decode(publicValues, (bytes32, uint64, bytes32, bytes32, bool, bool)); // 2) bind the proof to THIS client's trusted anchor + monotonic head. if (validatorSetRoot != trustedValidatorSetRoot) { revert ValidatorSetMismatch(validatorSetRoot, trustedValidatorSetRoot); } if (!finalized) revert NotFinalized(); if (blockNumber <= finalizedNumber) revert StaleFinality(blockNumber, finalizedNumber); if (blockHash == bytes32(0)) revert ZeroBlockHash(); // 3) advance the tracked finalized head. finalizedNumber = blockNumber; finalizedBlockHash = blockHash; lastCommittedSealHash = committedSealHash; lastFalconVerified = falconVerified; unchecked { updateCount += 1; } emit FinalityAdvanced(blockNumber, blockHash, committedSealHash, falconVerified, msg.sender); } /// @notice Stateless verification: reverts if the proof is invalid for PROGRAM_VKEY /// or the public values fail the anchor/finalized binding; otherwise returns /// the decoded fields. Records nothing (no monotonicity constraint). function verify(bytes calldata publicValues, bytes calldata proof) external view returns ( bytes32 blockHash, uint64 blockNumber, bytes32 committedSealHash, bytes32 validatorSetRoot, bool finalized, bool falconVerified ) { if (publicValues.length != 192) revert BadPublicValuesLength(publicValues.length); ISp1Verifier(SP1_VERIFIER).verifyProof(PROGRAM_VKEY, publicValues, proof); (blockHash, blockNumber, committedSealHash, validatorSetRoot, finalized, falconVerified) = abi.decode(publicValues, (bytes32, uint64, bytes32, bytes32, bool, bool)); if (validatorSetRoot != trustedValidatorSetRoot) { revert ValidatorSetMismatch(validatorSetRoot, trustedValidatorSetRoot); } if (!finalized) revert NotFinalized(); } }