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.
418 lines
22 KiB
Solidity
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;
|
|
}
|
|
}
|