// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; /// @dev Canonical SP1 verifier / SP1VerifierGateway ABI: verifyProof RETURNS /// NOTHING and REVERTS on an invalid proof. interface ISp1Verifier { function verifyProof( bytes32 programVKey, bytes calldata publicValues, bytes calldata proof ) external view; } /** * @title AereProofAggregatorV2 — on-chain anchor for RECURSIVE SP1 proof aggregation. * CORRECTED fork of AereProofAggregator 0x6a260238…F84C5 (see THE FIX below). * * @notice Verifies a single SP1 Groth16 "proof of proofs": the aggregation guest * recursively verified N inner SP1 proofs inside the zkVM via * `verify_sp1_proof`, and committed one composite digest binding all N * (vkey, publicValues) pairs. This contract verifies that Groth16 proof * through the SP1 gateway, recomputes the composite from the supplied * inner data to bind it to the proof, REQUIRES every folded inner program * to be an ALLOWED program, and records the aggregation. * * COMPOSITE (recomputed here byte-for-byte with the guest): * composite = sha256( * uint32_be(n) * || (innerVKeyDigest_0 || innerPvHash_0) * || ... * || (innerVKeyDigest_{n-1} || innerPvHash_{n-1}) * ) * where * innerVKeyDigest_i : the KoalaBear (SP1Field) verifying-key digest the * recursion verifier uses (SP1VerifyingKey::hash_u32, * big-endian). This is what `verify_sp1_proof` * actually checked, so binding it here is maximally * sound. * innerPvHash_i : sha256 of inner proof i's public values. * * @dev ------------------------------------------------------------------ * THE FIX (vs V1 0x6a260238…F84C5) — INNER VKEYS COUNTED, NEVER ENFORCED. * * THE GUEST IS UNCHANGED AND WAS NEVER THE PROBLEM. It is genuinely * recursive, and the composite it commits ALREADY binds every inner vkey * digest. V1 even recomputed that composite correctly, so V1 always KNEW * exactly which programs had been folded. * * V1's flaw was what it DID with that knowledge: nothing. It merely COUNTED * how many inner vkeys happened to be registered into a `recognized` field * and recorded the aggregation regardless: * for (i) { if (innerPrograms[digests[i]].known) recognized++; } * An unregistered inner program was not an error — just a lower number. * * THE ATTACK. An attacker writes an SP1 guest with NO CONSTRAINTS AT ALL: * fn main() { sp1_zkvm::io::commit_slice(&attacker_chosen_bytes); } * and has it commit, say, the exact AereZKScreen public-values tuple. They * prove it (trivially — the program asserts nothing), fold it with the real * aggregation guest (which honestly verifies it, because it IS a valid SP1 * proof of that vacuous program), and submit. V1 records an aggregation that * `exists`, carries the pinned aggVKey, and looks legitimate — differing from * a genuine one only by `recognized < count`, a field nothing enforced. * * V2 REQUIRES every inner vkey digest to be an ALLOWED program and REVERTS * with `UnregisteredInnerProgram(digest, index)` otherwise. The attacker's * vacuous guest has a different vkey (a vkey IS a commitment to the program), * so it can never be in the allowed set. Both `recordAggregation` and the * `verifyAggregation` view enforce it — mirroring the AereZkEthLightClientV2 * lesson that a view used as an oracle must bind exactly like the stateful path. * * THE ALLOWED SET IS SEEDED AT DEPLOY (constructor), so this contract is * FUNCTIONAL ON ARRIVAL with the same three programs V1 carried — it needs no * post-deploy signature to be safe. The Foundation may ADD programs, and * {sealRegistry} is a ONE-WAY latch that makes the set permanently IMMUTABLE. * `recognized` is retained for read-shape compatibility but is now an * INVARIANT: it always equals `count`, because an unregistered inner reverts. * ------------------------------------------------------------------ * * @dev HONEST SCOPE / RESIDUAL TRUST — read before quoting. * * What this proves: "N proofs, each from an ALLOWED program, exist with * these public values, and one Groth16 proof attests all N were verified * recursively in-zkVM". That is a real, strong statement — and it is the * statement V1 failed to make. * * What this does NOT prove: the SEMANTICS of the inner public values. The * contract does not know what a zkscreen tuple means; it only knows the * program that produced it is allowed. A consumer must still interpret the * inner public values against the program's own spec. * * REGISTRATION IS A TRUST ACTION. Allowing a program asserts that program's * constraints are sound. A bad registration is as damaging as the V1 bug. * This is why {sealRegistry} exists: once the set is final, seal it and the * admin surface is gone forever. Until sealed, the owner is trusted. * * VKEY ENCODINGS. `innerVKeyDigest` is the KoalaBear digest the recursion * verifier checks; `onChainVKey` is the BN254 bytes32 published for the same * program. They are two encodings of one key; the registry records the * mapping so each folded program can be labelled and cross-checked. The * ENFORCED value is the KoalaBear digest — the one the proof actually binds. * * NOT QUANTUM-SAFE, TRUST-MINIMIZED NOT TRUSTLESS: SP1 Groth16 over BN254 is * classical. This is an APPLICATION-layer contract; it does not touch AERE * consensus (still classical ECDSA QBFT). * * IMMUTABILITY: SP1_VERIFIER is fixed at deploy. Recorded aggregations cannot * be altered or removed. */ contract AereProofAggregatorV2 is Ownable { address public immutable SP1_VERIFIER; /// @notice Registered aggregation-program vkeys (BN254 bytes32) allowed to be /// routed to the gateway, with a human description. mapping(bytes32 => bool) public isAggProgram; mapping(bytes32 => string) public aggProgramDesc; struct InnerProgram { bool known; bytes32 onChainVKey; // BN254 bytes32 published for this program string label; // e.g. "zkscreen", "over18", "zkml-mnist" } /// @notice KoalaBear inner vkey digest => registered program metadata. /// An inner proof whose digest is NOT `known` here is REJECTED (the V2 fix). mapping(bytes32 => InnerProgram) public innerPrograms; /// @notice Once true, the allowed set is IMMUTABLE forever: no program can be /// added or changed, by anyone, including the owner. One-way latch. bool public registrySealed; struct Aggregation { bool exists; bytes32 aggVKey; uint32 count; uint32 recognized; // INVARIANT in V2: always == count (unregistered inners revert) uint64 at; address submitter; } /// @notice composite digest => recorded aggregation. mapping(bytes32 => Aggregation) public aggregations; uint256 public aggregationCount; /* --------------------------------- events ------------------------------- */ event AggProgramRegistered(bytes32 indexed aggVKey, string description); event InnerProgramRegistered(bytes32 indexed innerVKeyDigest, bytes32 onChainVKey, string label); event RegistrySealed(address indexed by, uint256 at); event AggregationVerified( bytes32 indexed composite, bytes32 indexed aggVKey, uint32 count, uint32 recognized, bytes32[] innerVKeyDigests, address indexed submitter, uint256 at ); /* --------------------------------- errors ------------------------------- */ error ZeroAddress(); error UnknownAggProgram(bytes32 aggVKey); error LengthMismatch(uint256 vkeys, uint256 pvHashes); error EmptyAggregation(); error BadPublicValuesLength(uint256 got); error CompositeMismatch(bytes32 recomputed, bytes32 committed); error InvalidProof(); error VerifierHasNoCode(); error ZeroVKey(); error RegistryIsSealed(); error SeedLengthMismatch(); /// @notice THE V2 FIX: a folded inner program that is not in the allowed set. error UnregisteredInnerProgram(bytes32 innerVKeyDigest, uint256 index); /* ----------------------------- constructor ------------------------------ */ /** * @param sp1Verifier the live SP1VerifierGateway. * @param initialOwner the account that may extend the allowed set (Foundation * or a Timelock). Set to address(0) ONLY if `seal` is true. * @param aggVKeys aggregation-program vkeys to allow at deploy. * @param aggDescs matching descriptions. * @param innerDigests KoalaBear inner vkey digests to allow at deploy. * @param innerOnChainVKeys matching BN254 on-chain vkeys. * @param innerLabels matching labels. * @param seal if true, seal the registry immediately: the seeded set * becomes IMMUTABLE and ownership is renounced in the same * transaction (no admin ever exists). */ constructor( address sp1Verifier, address initialOwner, bytes32[] memory aggVKeys, string[] memory aggDescs, bytes32[] memory innerDigests, bytes32[] memory innerOnChainVKeys, string[] memory innerLabels, bool seal ) { if (sp1Verifier == address(0)) revert ZeroAddress(); // A code-less verifier would make verifyProof (which returns no data) succeed // silently, accepting every "proof". Removes a deploy-time footgun. if (sp1Verifier.code.length == 0) revert VerifierHasNoCode(); if (aggVKeys.length != aggDescs.length) revert SeedLengthMismatch(); if (innerDigests.length != innerOnChainVKeys.length || innerDigests.length != innerLabels.length) { revert SeedLengthMismatch(); } SP1_VERIFIER = sp1Verifier; for (uint256 i; i < aggVKeys.length; ++i) { _registerAggProgram(aggVKeys[i], aggDescs[i]); } for (uint256 i; i < innerDigests.length; ++i) { _registerInnerProgram(innerDigests[i], innerOnChainVKeys[i], innerLabels[i]); } if (seal) { registrySealed = true; emit RegistrySealed(msg.sender, block.timestamp); _transferOwnership(address(0)); // no admin, ever } else { if (initialOwner == address(0)) revert ZeroAddress(); _transferOwnership(initialOwner); } } /* ------------------------------ governance ------------------------------ */ function registerAggProgram(bytes32 aggVKey, string calldata description) external onlyOwner { if (registrySealed) revert RegistryIsSealed(); _registerAggProgram(aggVKey, description); } function registerInnerProgram( bytes32 innerVKeyDigest, bytes32 onChainVKey, string calldata label ) external onlyOwner { if (registrySealed) revert RegistryIsSealed(); _registerInnerProgram(innerVKeyDigest, onChainVKey, label); } /// @notice Permanently freeze the allowed set. ONE-WAY: after this, no program can /// ever be added or changed, and ownership is renounced. The contract /// becomes fully immutable — the strongest form of the V2 fix. function sealRegistry() external onlyOwner { if (registrySealed) revert RegistryIsSealed(); registrySealed = true; emit RegistrySealed(msg.sender, block.timestamp); _transferOwnership(address(0)); } function _registerAggProgram(bytes32 aggVKey, string memory description) internal { if (aggVKey == bytes32(0)) revert ZeroVKey(); isAggProgram[aggVKey] = true; aggProgramDesc[aggVKey] = description; emit AggProgramRegistered(aggVKey, description); } function _registerInnerProgram(bytes32 innerVKeyDigest, bytes32 onChainVKey, string memory label) internal { // A zero digest must never be allowed: it is the default value of an // unregistered slot and would turn the enforcement gate into a no-op. if (innerVKeyDigest == bytes32(0)) revert ZeroVKey(); innerPrograms[innerVKeyDigest] = InnerProgram({known: true, onChainVKey: onChainVKey, label: label}); emit InnerProgramRegistered(innerVKeyDigest, onChainVKey, label); } /* --------------------------- record aggregation ------------------------- */ /// @notice Verify a recursive aggregation Groth16 proof and record it. /// REVERTS unless EVERY folded inner program is in the allowed set (V2 fix). /// @param aggVKey registered aggregation-program vkey (BN254 bytes32) /// @param innerVKeyDigests KoalaBear vkey digests of the folded inner proofs /// @param innerPvHashes sha256 of each inner proof's public values /// @param publicValues the aggregation proof's committed 32-byte composite /// @param proof the SP1 Groth16 proof bytes (selector 0x4388a21c) /// @return composite the verified composite digest function recordAggregation( bytes32 aggVKey, bytes32[] calldata innerVKeyDigests, bytes32[] calldata innerPvHashes, bytes calldata publicValues, bytes calldata proof ) external returns (bytes32 composite) { composite = _check(aggVKey, innerVKeyDigests, innerPvHashes, publicValues, proof); uint32 n = uint32(innerVKeyDigests.length); aggregations[composite] = Aggregation({ exists: true, aggVKey: aggVKey, count: n, recognized: n, // invariant: enforcement above guarantees all are registered at: uint64(block.timestamp), submitter: msg.sender }); aggregationCount += 1; emit AggregationVerified(composite, aggVKey, n, n, innerVKeyDigests, msg.sender, block.timestamp); } /* ----------------------------------- views ------------------------------ */ /// @notice Stateless verification: recomputes the composite, checks it equals the /// committed public values, ENFORCES that every inner program is allowed /// (the V2 fix — a view used as an oracle must bind like the stateful path), /// and verifies the Groth16 proof. Reverts if anything is invalid; records nothing. function verifyAggregation( bytes32 aggVKey, bytes32[] calldata innerVKeyDigests, bytes32[] calldata innerPvHashes, bytes calldata publicValues, bytes calldata proof ) external view returns (bytes32 composite) { composite = _check(aggVKey, innerVKeyDigests, innerPvHashes, publicValues, proof); } function getAggregation(bytes32 composite) external view returns (bool exists, bytes32 aggVKey, uint32 count, uint32 recognized, uint64 at, address submitter) { Aggregation memory a = aggregations[composite]; return (a.exists, a.aggVKey, a.count, a.recognized, a.at, a.submitter); } /// @notice True iff every supplied inner vkey digest is an allowed program. /// Convenience for callers that want to check before spending gas. function areInnerProgramsAllowed(bytes32[] calldata innerVKeyDigests) external view returns (bool) { for (uint256 i; i < innerVKeyDigests.length; ++i) { if (!innerPrograms[innerVKeyDigests[i]].known) return false; } return true; } /* ---------------------------------- internal ---------------------------- */ /// @dev The single gate both entry points go through: agg-program allowed, /// lengths sane, EVERY inner program allowed (the V2 fix), composite bound /// to the committed public values, and the Groth16 proof valid. function _check( bytes32 aggVKey, bytes32[] calldata innerVKeyDigests, bytes32[] calldata innerPvHashes, bytes calldata publicValues, bytes calldata proof ) internal view returns (bytes32 composite) { if (!isAggProgram[aggVKey]) revert UnknownAggProgram(aggVKey); uint256 n = innerVKeyDigests.length; if (n != innerPvHashes.length) revert LengthMismatch(n, innerPvHashes.length); if (n == 0) revert EmptyAggregation(); if (publicValues.length != 32) revert BadPublicValuesLength(publicValues.length); // THE V2 FIX: every folded inner program must be ALLOWED. V1 only counted these. // Done BEFORE the proof verify so an unregistered program is rejected cheaply and // with a precise, debuggable error rather than an opaque gateway revert. for (uint256 i; i < n; ++i) { if (!innerPrograms[innerVKeyDigests[i]].known) { revert UnregisteredInnerProgram(innerVKeyDigests[i], i); } } // recompute the composite exactly as the aggregation guest committed it. composite = _composite(innerVKeyDigests, innerPvHashes); bytes32 committed; assembly { committed := calldataload(publicValues.offset) } if (composite != committed) revert CompositeMismatch(composite, committed); // verify the Groth16 proof of proofs through the SP1 gateway. try ISp1Verifier(SP1_VERIFIER).verifyProof(aggVKey, publicValues, proof) { // verified: did not revert } catch { revert InvalidProof(); } } function _composite(bytes32[] calldata innerVKeyDigests, bytes32[] calldata innerPvHashes) internal pure returns (bytes32) { uint256 n = innerVKeyDigests.length; bytes memory packed = abi.encodePacked(uint32(n)); for (uint256 i; i < n; ++i) { packed = bytes.concat(packed, innerVKeyDigests[i], innerPvHashes[i]); } return sha256(packed); } }