aere-contracts/contracts/mpc/AerePQAggregateVerifier.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

418 lines
22 KiB
Solidity

// 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;
}
}