Aere Network public source. Everything here can be checked against the live chain (chain id 2800, https://rpc.aere.network). Scope note, stated up front rather than buried: consensus on chain 2800 is classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at the signature, precompile, account and transport layers. Nothing here makes the consensus post-quantum, and no document in it should be read as claiming so.
389 lines
18 KiB
Solidity
389 lines
18 KiB
Solidity
// 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);
|
|
}
|
|
}
|