// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import {ISP1Verifier} from "../zkverify/ISP1Verifier.sol"; /// @dev Minimal view surface of AerePQAttestationKeyRegistry this verifier reads. interface IAerePQAttestationKeyRegistry { function validatorSetRoot() external view returns (bytes32); function validatorCount() external view returns (uint256); } /** * @title AereFinalityCertificateVerifier, the on-chain half of Aere Network's ADDITIVE * Post-Quantum Finality Certificate (chain 2800). * * @notice A Post-Quantum Finality Certificate is ONE succinct proof that a quorum of Aere * validators attested to a finalized block with their HASH-BASED (post-quantum) * attestation keys. Anyone, a light client, a bridge, an L2, or an AI agent, can * verify with a SINGLE `verifyProof` call, at a cost INDEPENDENT of the validator * count N, that an Aere block reached PQ finality, WITHOUT trusting the classical * ECDSA seals and WITHOUT changing consensus. * * The off-chain aggregation circuit (the [MEASURE] research component) proves, * inside a zkVM, that at least `ceil(2N/3)` DISTINCT validators, each committed * under the registry's `validatorSetRoot`, produced a valid hash-based signature * over the block. This contract does the cheap on-chain part: it binds the proof's * public values to the live attestation-key registry, enforces the quorum * threshold, and verifies the one proof through the live SP1 gateway. * * @dev THE HONEST BOUNDARY, read this before trusting or describing anything. * * 1. ADDITIVE, NOT A CONSENSUS FLIP. Aere consensus is STILL classical ECDSA QBFT. * Validators keep signing ECDSA committed seals for block production and ALSO * sign a hash-based attestation over each finalized block. This certificate * proves the SECOND, additive quorum. It does not replace, gate, or flip * consensus, it needs no re-genesis, and PQ finality is NOT live. Never imply * Aere consensus is post-quantum: it is not. * * 2. THE OFF-CHAIN AGGREGATION zkVM CIRCUIT IS NOT IMPLEMENTED HERE. What is built * and tested is the ON-CHAIN side: this verifier, the registry, the public-input * binding scheme, the fail-closed checks, and a mock-gateway test. The zkVM guest * that verifies N hash-based (XMSS / leanSig / SLH-DSA) signatures against the * validator-set root and emits the public values is REAL, NOVEL cryptographic * engineering that is NOT in this repository and is marked [MEASURE] in * docs/AERE-PQ-FINALITY-CERTIFICATE.md. Verifying a stateful hash-based scheme * (XMSS) or a leanSig scheme inside a zkVM, or an ML-DSA-in-zkVM alternative, is * the research target. Do NOT describe the certificate as working end-to-end: the * on-chain verifier is proven against a MOCK SP1 gateway that asserts the bound * public values, the same test double the aggregate verifier and the light clients * use. * * 3. WHY THIS IS QUANTUM-SAFE WHERE THE ECDSA PATH IS NOT. The attestation legs are * hash-based signatures (post-quantum from hash collision / preimage resistance * alone), and the aggregation proof is a hash-based STARK (SP1's core proof is a * hash-based FRI STARK, Shor-resistant). This differs from AereZkQbftLightClient, * which proves the CLASSICAL ECDSA committed-seal quorum and, when wrapped to * Groth16 over BN254, is quantum-vulnerable. A quantum adversary that can forge * ECDSA still cannot forge the hash-based attestations, so a PQ certificate stays * sound. HONESTY: this quantum-safety property is a property of the DESIGN and of * the [MEASURE] circuit once it is real; the on-chain contract alone does not * establish it, and a Groth16-wrapped SP1 proof reintroduces a BN254 leg (see the * doc for the STARK-verifier note). * * 4. THE PINNED CIRCUIT. PROGRAM_VKEY is the SP1 verification key of the ONE audited * aggregation circuit this verifier accepts, fixed at construction. No caller can * substitute a different circuit: every certificate is proven by the same pinned * program through the same immutable gateway. * * 5. FAIL-CLOSED. A certificate is accepted ONLY when ALL hold, and rejected * otherwise: * - the registry validator set is non-empty (else EmptyValidatorSet), * - the proof's validatorSetRoot equals the registry's CURRENT root * (else ValidatorSetRootMismatch) -- so a rotation invalidates old certs, * - the proof's validatorSetSize equals the registry's current N (else SizeMismatch), * - the proof's attestationDomain equals this verifier's bound domain * (else DomainMismatch) -- binds chainId + registry, blocks cross-context replay, * - the proof's quorumCount is >= ceil(2N/3) (else BelowQuorum), * - the proof's blockHash is non-zero (else ZeroBlockHash), * - the SP1 proof verifies against the pinned vkey through the gateway * (verifyProof REVERTS on an invalid proof). * * @dev PUBLIC-VALUES LAYOUT. The aggregation guest MUST output its public values as the * fixed 192-byte ABI encoding of six words: * abi.encode( * bytes32 blockHash, // the finalized Aere block hash * uint256 blockHeight, // its height * bytes32 validatorSetRoot, // recomputed IN the circuit from the witnessed keys * uint256 validatorSetSize, // N committed under the root * uint256 quorumCount, // number of DISTINCT valid hash-based attestations, >= ceil(2N/3) * bytes32 attestationDomain // domain tag; each key signed keccak256(domain, blockHash, blockHeight) * ) * The circuit is responsible for: recomputing validatorSetRoot from the witnessed * keys, verifying each counted hash-based signature against a DISTINCT committed key * over the domain-bound block message, and never double-counting a validator. This * contract trusts the proof for those facts and binds the public values to the live * registry and to the quorum threshold. See docs/AERE-PQ-FINALITY-CERTIFICATE.md. */ contract AereFinalityCertificateVerifier { /// @notice Exact byte length of the SP1 public values (six 32-byte words). uint256 internal constant PUBLIC_VALUES_LEN = 192; /// @notice Domain-separation prefix for the attestation message binding. bytes32 public constant DOMAIN_PREFIX = keccak256("AerePQFinalityCertificate.v1"); // ========================================================================== // Immutable wiring // ========================================================================== /// @notice The deployed SP1 verifier gateway that checks certificate proofs on-chain. /// On Aere Network mainnet (chain 2800) this is the canonical Succinct gateway /// at 0x9ca479C8c52C0EbB4599319a36a5a017BCC70628. verifyProof REVERTS on an /// invalid proof, which is the fail-closed backbone of this verifier. ISP1Verifier public immutable SP1_GATEWAY; /// @notice The SP1 verification key of the ONE audited aggregation circuit this verifier /// accepts. Fixed at construction; no caller can substitute another circuit. bytes32 public immutable PROGRAM_VKEY; /// @notice The post-quantum attestation-key registry this verifier binds certificates to. IAerePQAttestationKeyRegistry public immutable REGISTRY; /// @notice The domain tag the circuit must bind: keccak256(DOMAIN_PREFIX, chainId, registry). /// The message each validator's hash-based key signed is /// keccak256(abi.encode(attestationDomain, blockHash, blockHeight)); pinning the /// domain blocks replay of a certificate to a different chain or registry. bytes32 public immutable attestationDomain; // ========================================================================== // Types // ========================================================================== /// @notice A recorded PQ finality certificate, keyed by block hash. struct Certificate { bool exists; uint64 blockHeight; uint32 quorumCount; uint32 validatorSetSize; bytes32 validatorSetRoot; uint64 recordedBlock; address prover; } // ========================================================================== // Storage // ========================================================================== // finalized Aere block hash => recorded certificate mapping(bytes32 => Certificate) private _certificates; /// @notice The highest Aere block height recorded PQ-final so far (a convenience head). uint64 public highestFinalizedHeight; /// @notice The block hash at `highestFinalizedHeight`. bytes32 public highestFinalizedBlockHash; /// @notice Number of certificates recorded. uint64 public certificateCount; // ========================================================================== // Events // ========================================================================== event CertificateRecorded( bytes32 indexed blockHash, uint64 indexed blockHeight, uint32 quorumCount, uint32 validatorSetSize, bytes32 validatorSetRoot, address prover ); // ========================================================================== // Errors // ========================================================================== error ZeroGateway(); error ZeroVKey(); error ZeroRegistry(); error VerifierHasNoCode(); error BadPublicValues(); // wrong length, not the fixed 192-byte layout error EmptyValidatorSet(); error ValidatorSetRootMismatch(bytes32 provenRoot, bytes32 registeredRoot); error SizeMismatch(uint256 provenSize, uint256 registeredSize); error DomainMismatch(bytes32 provenDomain, bytes32 expectedDomain); error BelowQuorum(uint256 provenQuorum, uint256 requiredQuorum); error ZeroBlockHash(); error BlockHashMismatch(bytes32 provenBlockHash, bytes32 claimedBlockHash); error BlockHeightMismatch(uint256 provenHeight, uint256 claimedHeight); // ========================================================================== // Construction // ========================================================================== /// @param gateway the deployed SP1 verifier gateway (chain 2800: 0x9ca4…0628). /// @param programVKey the SP1 vkey of the one aggregation circuit this verifier accepts. /// @param registry the AerePQAttestationKeyRegistry whose root certificates bind to. constructor(address gateway, bytes32 programVKey, address registry) { if (gateway == address(0)) revert ZeroGateway(); // A code-less gateway would make verifyProof (which returns no data) succeed silently, // accepting every "proof". The deployed value must be the live SP1 gateway. if (gateway.code.length == 0) revert VerifierHasNoCode(); if (programVKey == bytes32(0)) revert ZeroVKey(); if (registry == address(0)) revert ZeroRegistry(); SP1_GATEWAY = ISP1Verifier(gateway); PROGRAM_VKEY = programVKey; REGISTRY = IAerePQAttestationKeyRegistry(registry); attestationDomain = keccak256(abi.encode(DOMAIN_PREFIX, block.chainid, registry)); } // ========================================================================== // Certificate verification // ========================================================================== /** * @notice STRICT verification: reverts (fail-closed) unless the certificate proves a * >= ceil(2N/3) hash-based attestation quorum over its block against the live * registry. Verification is a SINGLE gateway verifyProof call; the gas does NOT * grow with N (no per-validator work happens on-chain). Callable as a view by * any relying contract. Returns the finalized (blockHash, blockHeight) on success. * * @param publicValues the SP1 public values, the fixed 192-byte layout documented above. * @param proof the SP1 proof; its first 4 bytes select the gateway route. */ function verifyCertificate(bytes calldata publicValues, bytes calldata proof) public view returns (bytes32 blockHash, uint64 blockHeight) { ( bytes32 pvBlockHash, uint256 pvBlockHeight, bytes32 pvRoot, uint256 pvSize, uint256 pvQuorum, bytes32 pvDomain ) = _decode(publicValues); // Bind to the LIVE registry state: the current root and size are the source of truth. uint256 n = REGISTRY.validatorCount(); if (n == 0) revert EmptyValidatorSet(); bytes32 registeredRoot = REGISTRY.validatorSetRoot(); if (pvRoot != registeredRoot) revert ValidatorSetRootMismatch(pvRoot, registeredRoot); if (pvSize != n) revert SizeMismatch(pvSize, n); if (pvDomain != attestationDomain) revert DomainMismatch(pvDomain, attestationDomain); uint256 required = _quorumThreshold(n); if (pvQuorum < required) revert BelowQuorum(pvQuorum, required); if (pvBlockHash == bytes32(0)) revert ZeroBlockHash(); // Single on-chain proof check. verifyProof reverts on an invalid proof, so a bad // proof reverts the whole call and finalizes nothing. This is the only cryptographic // gate and its cost is fixed by the SP1 verifier, independent of N. SP1_GATEWAY.verifyProof(PROGRAM_VKEY, publicValues, proof); return (pvBlockHash, uint64(pvBlockHeight)); } /** * @notice NON-REVERTING verification: returns true iff `verifyCertificate` would succeed, * and false on ANY failure (malformed public values, a binding mismatch, below * quorum, or an invalid proof). For consumers that prefer a boolean gate. */ function tryVerifyCertificate(bytes calldata publicValues, bytes calldata proof) external view returns (bool) { try this.verifyCertificate(publicValues, proof) returns (bytes32, uint64) { return true; } catch { return false; } } /** * @notice STRICT verification bound to a CLAIMED block: like `verifyCertificate` but * additionally requires the proof to finalize EXACTLY (claimedBlockHash, * claimedHeight). A consumer that wants to confirm a SPECIFIC block is PQ-final * (a bridge releasing funds for block X) uses this so a certificate for a * different block cannot be substituted. Reverts BlockHashMismatch / * BlockHeightMismatch on a mismatch. */ function assertFinalizes( bytes32 claimedBlockHash, uint64 claimedHeight, bytes calldata publicValues, bytes calldata proof ) public view returns (bool) { (bytes32 blockHash, uint64 blockHeight) = verifyCertificate(publicValues, proof); if (blockHash != claimedBlockHash) revert BlockHashMismatch(blockHash, claimedBlockHash); if (blockHeight != claimedHeight) revert BlockHeightMismatch(blockHeight, claimedHeight); return true; } /// @notice Non-reverting variant of `assertFinalizes`. function tryAssertFinalizes( bytes32 claimedBlockHash, uint64 claimedHeight, bytes calldata publicValues, bytes calldata proof ) external view returns (bool) { try this.assertFinalizes(claimedBlockHash, claimedHeight, publicValues, proof) returns (bool ok) { return ok; } catch { return false; } } // ========================================================================== // Certificate recording // ========================================================================== /** * @notice STATE-CHANGING: verify the certificate (strict, fail-closed) and record it, * keyed by the finalized block hash, so a consumer contract can later gate on * PQ finality via `isFinalPQ`. Reverts unless the proof verifies, so a recorded * certificate is always backed by a valid proof over a quorum. Permissionless. * * @dev Re-recording the same block hash refreshes the record's provenance; it grants * no new authority. The head (highestFinalizedHeight / BlockHash) advances only * on a strictly greater height. */ function recordCertificate(bytes calldata publicValues, bytes calldata proof) external returns (bytes32 blockHash, uint64 blockHeight) { (blockHash, blockHeight) = verifyCertificate(publicValues, proof); (, , bytes32 pvRoot, uint256 pvSize, uint256 pvQuorum, ) = _decode(publicValues); bool isNew = !_certificates[blockHash].exists; _certificates[blockHash] = Certificate({ exists: true, blockHeight: blockHeight, quorumCount: uint32(pvQuorum), validatorSetSize: uint32(pvSize), validatorSetRoot: pvRoot, recordedBlock: uint64(block.number), prover: msg.sender }); if (isNew) certificateCount += 1; if (blockHeight > highestFinalizedHeight) { highestFinalizedHeight = blockHeight; highestFinalizedBlockHash = blockHash; } emit CertificateRecorded( blockHash, blockHeight, uint32(pvQuorum), uint32(pvSize), pvRoot, msg.sender ); } // ========================================================================== // Consumer gating / views // ========================================================================== /// @notice True iff a PQ finality certificate has been recorded for `blockHash`. This is /// the gate a bridge / light client / L2 / AI agent checks before acting on Aere /// finality quantum-safely. function isFinalPQ(bytes32 blockHash) external view returns (bool) { return _certificates[blockHash].exists; } /// @notice True iff `blockHash` is recorded PQ-final AND at exactly `blockHeight`. function isFinalPQAtHeight(bytes32 blockHash, uint64 blockHeight) external view returns (bool) { Certificate storage c = _certificates[blockHash]; return c.exists && c.blockHeight == blockHeight; } /// @notice Read a recorded certificate. `exists` is false for an unknown block hash. function certificateOf(bytes32 blockHash) external view returns ( bool exists, uint64 blockHeight, uint32 quorumCount, uint32 validatorSetSize, bytes32 validatorSetRoot, uint64 recordedBlock, address prover ) { Certificate storage c = _certificates[blockHash]; return ( c.exists, c.blockHeight, c.quorumCount, c.validatorSetSize, c.validatorSetRoot, c.recordedBlock, c.prover ); } /// @notice The quorum threshold ceil(2N/3) for the current registry validator set. function currentQuorumThreshold() external view returns (uint256) { return _quorumThreshold(REGISTRY.validatorCount()); } /** * @notice Helper for off-chain tooling and the circuit harness: the exact 192-byte public * values a certificate finalizing (blockHash, blockHeight) with `quorumCount` * distinct attestations must emit, bound to the CURRENT registry root and size. */ function expectedPublicValues(bytes32 blockHash, uint64 blockHeight, uint256 quorumCount) external view returns (bytes memory) { return abi.encode( blockHash, uint256(blockHeight), REGISTRY.validatorSetRoot(), REGISTRY.validatorCount(), quorumCount, attestationDomain ); } /// @notice The message an attestation key signs for (blockHash, blockHeight) under this /// verifier's domain. The circuit checks each hash-based signature over THIS value. function attestationMessage(bytes32 blockHash, uint64 blockHeight) external view returns (bytes32) { return keccak256(abi.encode(attestationDomain, blockHash, blockHeight)); } // ========================================================================== // Internal // ========================================================================== /// @dev ceil(2N/3): the QBFT quorum. N=7 -> 5, N=9 -> 6, N=4 -> 3. function _quorumThreshold(uint256 n) internal pure returns (uint256) { return (2 * n + 2) / 3; } /// @dev Decode the fixed 192-byte public-values layout. Reverts BadPublicValues on any /// other length; a correct-length blob always decodes without reverting. function _decode(bytes calldata publicValues) internal pure returns ( bytes32 blockHash, uint256 blockHeight, bytes32 validatorSetRoot, uint256 validatorSetSize, uint256 quorumCount, bytes32 domain ) { if (publicValues.length != PUBLIC_VALUES_LEN) revert BadPublicValues(); (blockHash, blockHeight, validatorSetRoot, validatorSetSize, quorumCount, domain) = abi.decode(publicValues, (bytes32, uint256, bytes32, uint256, uint256, bytes32)); } }