// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import {ISP1Verifier} from "../zkverify/ISP1Verifier.sol"; /** * @title AerePQAggregateVerifier, constant-cost on-chain verification of a t-of-n * POST-QUANTUM committee authorization via ONE succinct SP1 proof. * * @notice One small SP1 proof attests that a t-of-n committee of NIST post-quantum * signatures (ML-DSA / Falcon) all signed a given message digest, so the * ON-CHAIN verification cost is INDEPENDENT of the committee size n. A relying * party registers a committee once (a root commitment to the n authorized PQC * public keys, a threshold t, a size n, and a scheme), and thereafter every * authorization is a SINGLE gateway verifyProof call whose gas does not grow * with n. This is the aggregation counterpart to AereThresholdPQCRegistry: that * registry verifies t INDEPENDENT PQC signatures on-chain (cost linear in t, * bounded by the per-transaction gas ceiling); this verifier moves the t * signature checks OFF-CHAIN into an SP1 zkVM circuit and verifies one proof. * * @dev THE HONEST BOUNDARY, read this before trusting or describing anything. * * 1. THE OFF-CHAIN SP1 AGGREGATION CIRCUIT IS NOT IMPLEMENTED HERE. What is built * and tested in this repository is the ON-CHAIN side: this verifier, the * public-input binding scheme, the committee registry, the fail-closed checks, * and the ERC-7579 module (AerePQAggregateModule). The zkVM guest program that * actually verifies n PQC signatures against the committee root and emits the * public values is REAL cryptographic engineering that is NOT in this contract * and is marked [MEASURE] in docs/AERE-PQ-AGGREGATE.md. Verifying lattice PQC * (especially Falcon's FFT / floating-point sampling) inside a zkVM is hard; * ML-DSA-65 (FIPS 204, integer arithmetic) is the tractable FIRST target. * Do NOT describe the aggregation as working end-to-end: the on-chain verifier * is proven against a MOCK SP1 gateway that checks the bound public values, the * same test double used by AereComputeMarketV3 and the light clients. * * 2. SCOPE. This is ACCOUNT / AUTHORIZATION aggregation, not consensus. It changes * nothing about Aere Network consensus, which remains classical ECDSA QBFT * (Foundation validators). What is post-quantum here is the AUTHORITY that * approves a message: it survives a quantum adversary that can forge ECDSA, * because the underlying committee legs are NIST PQC signatures, proven in the * zkVM. This contract holds NO funds and has NO admin or owner override. * * 3. THE PINNED CIRCUIT. AGGREGATE_PROGRAM_VKEY is the SP1 verification key of the * ONE audited aggregation circuit this verifier accepts, fixed at construction. * A registrant CANNOT substitute a different circuit: every committee is proven * by the same pinned program through the same immutable gateway. Deploy one * verifier per circuit (for example one for the ML-DSA-65 aggregation program). * * 4. FAIL-CLOSED. An authorization is accepted ONLY when ALL of the following hold, * and is rejected otherwise: * - the committee is registered (else UnknownCommittee), * - the proof's committee root equals the registered root (else CommitteeRootMismatch), * - the proof's threshold / size / scheme equal the registered committee * (else ThresholdMismatch / SizeMismatch / SchemeMismatch), * - the proof's message digest equals the digest the caller is authorizing * (else MessageDigestMismatch), * - the proof's proven valid-signature count is >= the committee threshold * (else BelowThreshold), * - the SP1 proof verifies against the pinned vkey through the gateway * (verifyProof REVERTS on an invalid proof). * The committee root is the sole authority binding: because the root commits to * the exact n public keys, a proof for a different key set produces a different * root and is rejected. Binding t, n and scheme in addition is defense in depth. * * @dev PUBLIC-VALUES LAYOUT. The SP1 guest program MUST output its public values as the * fixed 192-byte ABI encoding of six words: * abi.encode( * bytes32 committeeRoot, // recomputed IN the circuit from the witnessed n keys * uint256 threshold, // t the circuit enforced (validCount was compared to it) * uint256 committeeSize, // n * uint256 scheme, // PQC scheme id (see SCHEME_* constants) * bytes32 messageDigest, // the 32-byte message every counted signature signed * uint256 validCount // number of DISTINCT valid committee signatures, >= t * ) * The circuit is responsible for: recomputing committeeRoot from the witnessed keys, * verifying each of the validCount signatures against a DISTINCT committed key over * messageDigest, and never double-counting a key. This contract trusts the proof for * those facts and only binds the public values to a registered committee and to the * caller's claimed message. See docs/AERE-PQ-AGGREGATE.md. */ contract AerePQAggregateVerifier { // ========================================================================== // Scheme labels (informational) // ========================================================================== // // These name the PQC scheme a committee aggregates. They are bound into the // public values as defense in depth; the real circuit binding is the pinned // vkey. ML-DSA-65 is the tractable first aggregation target (see the doc). uint8 public constant SCHEME_FALCON512 = 1; uint8 public constant SCHEME_FALCON1024 = 2; uint8 public constant SCHEME_MLDSA44 = 3; uint8 public constant SCHEME_SLHDSA128S = 4; uint8 public constant SCHEME_MLDSA65 = 5; // FIPS 204 ML-DSA-65, the tractable first target /// @notice Exact byte length of the SP1 public values (six 32-byte words). uint256 internal constant PUBLIC_VALUES_LEN = 192; // ========================================================================== // Immutable wiring // ========================================================================== /// @notice The deployed SP1 verifier gateway that checks aggregation 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; a registrant cannot substitute another circuit. bytes32 public immutable AGGREGATE_PROGRAM_VKEY; // ========================================================================== // Types // ========================================================================== struct Committee { bool exists; uint8 scheme; // one of SCHEME_* (the whole committee shares one scheme) uint32 threshold; // t: distinct valid signatures the proof must attest (>= t) uint32 size; // n: number of authorized committee members address registrant; // who registered (informational; carries no authority) bytes32 root; // commitment to the n authorized PQC public keys } /// @notice A recorded authorization (provenance), keyed by (committeeId, messageDigest). struct Attestation { bool exists; uint32 validCount; // distinct valid signatures the proof attested uint64 blockNumber; // block the attestation was recorded } // ========================================================================== // Storage // ========================================================================== uint256 public committeeCount; mapping(uint256 => Committee) private _committees; // keccak256(committeeId, messageDigest) => recorded attestation mapping(bytes32 => Attestation) private _attestations; // ========================================================================== // Events // ========================================================================== event CommitteeRegistered( uint256 indexed committeeId, address indexed registrant, uint8 indexed scheme, uint32 threshold, uint32 size, bytes32 root ); event AggregateVerified( uint256 indexed committeeId, bytes32 indexed messageDigest, uint32 threshold, uint32 validCount, uint64 blockNumber ); // ========================================================================== // Errors // ========================================================================== error ZeroGateway(); error VerifierHasNoCode(); error ZeroVKey(); error BadCommitteeParams(); error ZeroRoot(); error BadScheme(uint8 scheme); error UnknownCommittee(uint256 committeeId); error BadPublicValues(); // wrong length, not the fixed 192-byte layout error CommitteeRootMismatch(bytes32 provenRoot, bytes32 registeredRoot); error ThresholdMismatch(uint256 provenThreshold, uint32 registeredThreshold); error SizeMismatch(uint256 provenSize, uint32 registeredSize); error SchemeMismatch(uint256 provenScheme, uint8 registeredScheme); error MessageDigestMismatch(bytes32 provenDigest, bytes32 claimedDigest); error BelowThreshold(uint256 provenValidCount, uint32 requiredThreshold); // ========================================================================== // Construction // ========================================================================== /// @param gateway the deployed SP1 verifier gateway (chain 2800: 0x9ca4…0628). /// @param aggregateProgramVKey the SP1 vkey of the one aggregation circuit this verifier accepts. constructor(address gateway, bytes32 aggregateProgramVKey) { if (gateway == address(0)) revert ZeroGateway(); // A code-less gateway would make verifyProof (which returns no data) succeed silently, // accepting every "proof" = post-quantum authorization bypass. Guard it exactly as the // sibling AereFinalityCertificateVerifier does. The deployed value must be the live SP1 gateway. if (gateway.code.length == 0) revert VerifierHasNoCode(); if (aggregateProgramVKey == bytes32(0)) revert ZeroVKey(); SP1_GATEWAY = ISP1Verifier(gateway); AGGREGATE_PROGRAM_VKEY = aggregateProgramVKey; } // ========================================================================== // Registration // ========================================================================== /** * @notice Register a t-of-n post-quantum committee for constant-cost aggregate verification. * @param root a binding commitment to the exact n authorized PQC public keys (for * example a Merkle root over the canonically ordered keys, or the keccak256 * of the canonical key list). The SP1 circuit recomputes THIS value from the * witnessed keys and emits it, so a proof for any other key set yields a * different root and is rejected. Must be non-zero. * @param threshold t, with 1 <= t <= n. The proof must attest at least t distinct valid signatures. * @param size n, the committee size (1 <= n <= 2^32 - 1). Bound into the public values. * @param scheme the committee's PQC scheme (one of SCHEME_*; must be non-zero). * @return committeeId the id of the new committee. * * @dev Permissionless: the registrant gains no privilege. Authority is ONLY the committed key * set behind `root`; there is no admin, no owner override, and no fund custody. The keys * themselves are NOT stored on-chain (storing them would reintroduce the O(n) cost this * design removes); the root is the sole on-chain commitment to them. */ function registerCommittee(bytes32 root, uint32 threshold, uint32 size, uint8 scheme) external returns (uint256 committeeId) { if (root == bytes32(0)) revert ZeroRoot(); if (size == 0 || threshold == 0 || threshold > size) revert BadCommitteeParams(); if (scheme == 0) revert BadScheme(scheme); committeeId = ++committeeCount; _committees[committeeId] = Committee({ exists: true, scheme: scheme, threshold: threshold, size: size, registrant: msg.sender, root: root }); emit CommitteeRegistered(committeeId, msg.sender, scheme, threshold, size, root); } // ========================================================================== // Aggregate verification // ========================================================================== /** * @notice STRICT verification: reverts (fail-closed) unless the aggregate proof attests that a * t-of-n committee signed `messageDigest`. Verification is a SINGLE gateway verifyProof * call; the gas does NOT grow with the committee size n (no per-member work happens * on-chain). Callable as a view by any relying contract. * * @param committeeId the registered committee whose root and threshold the proof must match. * @param messageDigest the 32-byte message the caller is authorizing; must equal the digest the * proof's public values bind (the message every counted signature signed). * @param publicValues the SP1 public values, the fixed 192-byte layout documented above. * @param proofBytes the SP1 proof; its first 4 bytes select the gateway route. * @return true on success (reverts otherwise). */ function verifyAggregate( uint256 committeeId, bytes32 messageDigest, bytes calldata publicValues, bytes calldata proofBytes ) public view returns (bool) { Committee storage c = _committees[committeeId]; if (!c.exists) revert UnknownCommittee(committeeId); ( bytes32 provenRoot, uint256 provenThreshold, uint256 provenSize, uint256 provenScheme, bytes32 provenDigest, uint256 provenValidCount ) = _decodePublicValues(publicValues); // Bind the proof to THIS committee and to the caller's claimed message. if (provenRoot != c.root) revert CommitteeRootMismatch(provenRoot, c.root); if (provenThreshold != c.threshold) revert ThresholdMismatch(provenThreshold, c.threshold); if (provenSize != c.size) revert SizeMismatch(provenSize, c.size); if (provenScheme != uint256(c.scheme)) revert SchemeMismatch(provenScheme, c.scheme); if (provenDigest != messageDigest) revert MessageDigestMismatch(provenDigest, messageDigest); if (provenValidCount < c.threshold) revert BelowThreshold(provenValidCount, c.threshold); // Single on-chain proof check. verifyProof reverts on an invalid proof, so a bad proof // reverts the whole call and authorizes nothing. This is the only cryptographic gate and // its cost is fixed by the SP1 verifier, independent of the committee size n. SP1_GATEWAY.verifyProof(AGGREGATE_PROGRAM_VKEY, publicValues, proofBytes); return true; } /** * @notice NON-REVERTING verification: returns true iff `verifyAggregate` would succeed, and * false on ANY failure (unknown committee, malformed public values, a binding mismatch, * below threshold, or an invalid proof). This is the variant ERC-4337 / ERC-7579 * validators use, since a validator SHOULD return a failure code rather than revert. */ function tryVerifyAggregate( uint256 committeeId, bytes32 messageDigest, bytes calldata publicValues, bytes calldata proofBytes ) external view returns (bool) { try this.verifyAggregate(committeeId, messageDigest, publicValues, proofBytes) returns (bool ok) { return ok; } catch { return false; } } /** * @notice STATE-CHANGING attestation: verify the aggregate (strict, fail-closed) and record an * on-chain provenance entry keyed by (committeeId, messageDigest). Reverts unless the * proof verifies, so a recorded attestation is always backed by a valid proof. * * @dev Replay note: this contract does NOT itself add nonce-based replay protection. The * `messageDigest` is expected to already be domain and context bound by the caller (for * ERC-4337 the digest is the userOpHash, which carries the account nonce; for a custom * use the caller binds chainId + contract + nonce into the digest, exactly as * AereComputeMarketV3.settlementDigest does). Re-recording the same digest simply overwrites * the same provenance slot; it grants no new authority. */ function recordAggregate( uint256 committeeId, bytes32 messageDigest, bytes calldata publicValues, bytes calldata proofBytes ) external returns (uint32 validCount) { // Strict verify first (reverts fail-closed on any problem). verifyAggregate(committeeId, messageDigest, publicValues, proofBytes); // Pull the proven count and threshold for the provenance record. (, , , , , uint256 provenValidCount) = _decodePublicValues(publicValues); validCount = uint32(provenValidCount); uint32 threshold = _committees[committeeId].threshold; _attestations[_attestationId(committeeId, messageDigest)] = Attestation({exists: true, validCount: validCount, blockNumber: uint64(block.number)}); emit AggregateVerified(committeeId, messageDigest, threshold, validCount, uint64(block.number)); } // ========================================================================== // Views / decode helpers // ========================================================================== /// @dev Decode the fixed 192-byte public-values layout. Reverts BadPublicValues on any other /// length; the fixed layout means a correct-length blob always decodes without reverting. function _decodePublicValues(bytes calldata publicValues) internal pure returns ( bytes32 committeeRoot, uint256 threshold, uint256 committeeSize, uint256 scheme, bytes32 messageDigest, uint256 validCount ) { if (publicValues.length != PUBLIC_VALUES_LEN) revert BadPublicValues(); (committeeRoot, threshold, committeeSize, scheme, messageDigest, validCount) = abi.decode(publicValues, (bytes32, uint256, uint256, uint256, bytes32, uint256)); } /// @notice Helper for off-chain tooling and the circuit harness: the exact 192-byte public /// values a proof for `committeeId` authorizing `messageDigest` with `validCount` /// distinct signatures must emit. Reverts on an unknown committee. function expectedPublicValues(uint256 committeeId, bytes32 messageDigest, uint256 validCount) external view returns (bytes memory) { Committee storage c = _committees[committeeId]; if (!c.exists) revert UnknownCommittee(committeeId); return abi.encode( c.root, uint256(c.threshold), uint256(c.size), uint256(c.scheme), messageDigest, validCount ); } /// @notice Deterministic id of the attestation for (committeeId, messageDigest). function _attestationId(uint256 committeeId, bytes32 messageDigest) internal pure returns (bytes32) { return keccak256(abi.encode(committeeId, messageDigest)); } function attestationId(uint256 committeeId, bytes32 messageDigest) external pure returns (bytes32) { return _attestationId(committeeId, messageDigest); } /// @notice True iff `committeeId` has been registered. function committeeExists(uint256 committeeId) external view returns (bool) { return _committees[committeeId].exists; } function getCommittee(uint256 committeeId) external view returns (bool exists, uint8 scheme, uint32 threshold, uint32 size, address registrant, bytes32 root) { Committee storage c = _committees[committeeId]; return (c.exists, c.scheme, c.threshold, c.size, c.registrant, c.root); } /// @notice Read a recorded attestation (provenance) for (committeeId, messageDigest). function getAttestation(uint256 committeeId, bytes32 messageDigest) external view returns (bool exists, uint32 validCount, uint64 blockNumber) { Attestation storage a = _attestations[_attestationId(committeeId, messageDigest)]; return (a.exists, a.validCount, a.blockNumber); } /// @notice True iff a valid aggregate has been recorded for (committeeId, messageDigest). function isAttested(uint256 committeeId, bytes32 messageDigest) external view returns (bool) { return _attestations[_attestationId(committeeId, messageDigest)].exists; } }