aere-contracts/contracts/modular/AerePQAggregateModule.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

226 lines
11 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @dev The subset of AerePQAggregateVerifier this module depends on. The verifier checks, via ONE
/// SP1 proof through the deployed gateway, that a registered t-of-n post-quantum committee
/// signed a message digest, at a gas cost independent of the committee size n.
interface IAerePQAggregateVerifier {
function committeeExists(uint256 committeeId) external view returns (bool);
function tryVerifyAggregate(
uint256 committeeId,
bytes32 messageDigest,
bytes calldata publicValues,
bytes calldata proofBytes
) external view returns (bool);
}
/// @dev Canonical ERC-4337 v0.7 packed user operation. Declared locally so this module is
/// self-contained and portable to ANY EVM chain and account stack (this repo pins an older
/// OpenZeppelin under the top-level import path; the canonical v5.6 layout is reproduced here
/// byte-for-byte). Matches OpenZeppelin interfaces/draft-IERC4337.sol PackedUserOperation and
/// AereEntryPointV2.PackedUserOperation. [VERIFY] confirm field-for-field against the account
/// stack you deploy under (Safe7579 / Kernel / Nexus all consume this exact struct).
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
/**
* @title AerePQAggregateModule, a portable ERC-7579 validator module for t-of-n POST-QUANTUM
* committee authorization via ONE succinct SP1 proof.
*
* @notice An ERC-7579 validator module (module type 1) that lets a Safe / Kernel / Nexus style
* modular smart account, on ANY EVM chain, use "a t-of-n post-quantum committee approved
* this" as its validation rule. The account delegates validation here; this module wraps
* AerePQAggregateVerifier, so validating a userOp is a SINGLE SP1 proof check whose gas is
* INDEPENDENT of the committee size n. The post-quantum committee legs are ML-DSA / Falcon
* signatures proven off-chain in the SP1 zkVM (that guest circuit is [MEASURE] and NOT part
* of this repository, see docs/AERE-PQ-AGGREGATE.md); on-chain the account checks one small
* proof.
*
* @dev ERC-7579 CONFORMANCE. Implements the type-1 validator surface from OpenZeppelin
* interfaces/draft-IERC7579.sol (IERC7579Validator):
* - onInstall(bytes) / onUninstall(bytes) / isModuleType(uint256) [IERC7579Module]
* - validateUserOp(PackedUserOperation, bytes32) returns (uint256) [ERC-4337 return packing]
* - isValidSignatureWithSender(address, bytes32, bytes) returns (bytes4) [ERC-1271]
* Singleton design: per-account state is keyed by msg.sender, exactly like
* AereSessionKeyValidator, so configuration is owner-only by construction (only the account
* itself, via its EntryPoint / self-call / root-owner gated install and execute paths, is
* ever msg.sender here).
*
* [VERIFY] The account is expected to pass, in userOp.signature and in the ERC-1271
* `signature`, ONLY this module's payload (the ABI encoding of the SP1 public values and
* proof). ERC-7579 accounts select the validator by an address prefix carried in the
* userOp NONCE key (Safe7579 / Kernel / Nexus) or, in some stacks, stripped from the
* signature before the module is called. This module assumes the routing prefix has already
* been removed by the account, matching the OZ AccountERC7579 dispatcher. Confirm the exact
* unwrapping against the specific account implementation you deploy under.
*
* @dev SCOPE. Account / authorization layer only. This does NOT make Aere Network consensus
* post-quantum (blocks stay classical ECDSA QBFT). It holds no funds and has no admin. What
* is post-quantum is the AUTHORITY that approves the account's userOps.
*/
contract AerePQAggregateModule {
// ERC-7579 / ERC-4337 return conventions.
uint256 internal constant VALIDATION_SUCCESS = 0;
uint256 internal constant SIG_VALIDATION_FAILED = 1;
uint256 internal constant MODULE_TYPE_VALIDATOR = 1;
bytes4 internal constant EIP1271_MAGIC = 0x1626ba7e;
bytes4 internal constant EIP1271_FAIL = 0xffffffff;
/// @notice The aggregation verifier this module validates against (immutable; set at deploy).
IAerePQAggregateVerifier public immutable VERIFIER;
/// @notice account => the committeeId that authorizes this account's userOps and ERC-1271
/// signatures. Zero means the module is not configured for that account.
mapping(address => uint256) public committeeOf;
event ModuleConfigured(address indexed account, uint256 indexed committeeId);
event ModuleDeconfigured(address indexed account, uint256 indexed committeeId);
error BadVerifier();
error AlreadyConfigured();
error NotConfigured();
error UnknownCommittee(uint256 committeeId);
constructor(address verifier) {
if (verifier == address(0)) revert BadVerifier();
VERIFIER = IAerePQAggregateVerifier(verifier);
}
// ==========================================================================
// ERC-7579 module base
// ==========================================================================
/// @notice ERC-7579 module-type check: this is a type-1 validator module.
function isModuleType(uint256 moduleTypeId) external pure returns (bool) {
return moduleTypeId == MODULE_TYPE_VALIDATOR;
}
/// @notice ERC-7579 accountId-style identifier for this module.
function name() external pure returns (string memory) {
return "aere.pq-aggregate-validator.1.0.0";
}
/// @notice Install on the calling account. initData layout: abi.encode(uint256 committeeId).
/// The committee must already be registered in the verifier.
function onInstall(bytes calldata data) external {
address account = msg.sender;
if (committeeOf[account] != 0) revert AlreadyConfigured();
uint256 committeeId = abi.decode(data, (uint256));
if (committeeId == 0 || !VERIFIER.committeeExists(committeeId)) revert UnknownCommittee(committeeId);
committeeOf[account] = committeeId;
emit ModuleConfigured(account, committeeId);
}
/// @notice Uninstall on the calling account, clearing its committee binding.
function onUninstall(bytes calldata) external {
address account = msg.sender;
uint256 committeeId = committeeOf[account];
if (committeeId == 0) revert NotConfigured();
delete committeeOf[account];
emit ModuleDeconfigured(account, committeeId);
}
/// @notice Rebind the calling account to a different registered committee (for example after a
/// committee rotation). Owner-only by the singleton msg.sender convention.
function setCommittee(uint256 committeeId) external {
address account = msg.sender;
if (committeeOf[account] == 0) revert NotConfigured();
if (committeeId == 0 || !VERIFIER.committeeExists(committeeId)) revert UnknownCommittee(committeeId);
committeeOf[account] = committeeId;
emit ModuleConfigured(account, committeeId);
}
// ==========================================================================
// ERC-7579 validation
// ==========================================================================
/**
* @notice Validate a userOp: succeed iff its signature carries a valid aggregate proof that the
* account's committee signed `userOpHash`. Called by the account (msg.sender == account).
* Returns the ERC-4337 code (0 = success, 1 = failure) and does NOT revert on a bad or
* malformed signature, per the ERC-7579 validator SHOULD.
*
* The `userOpHash` IS the message digest the committee signs, so the aggregate proof is
* bound to this exact operation (and, through the account nonce inside the userOpHash, is
* non-replayable). userOp.signature MUST be abi.encode(bytes publicValues, bytes proofBytes).
*/
function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash)
external
view
returns (uint256)
{
uint256 committeeId = committeeOf[msg.sender];
if (committeeId == 0) return SIG_VALIDATION_FAILED;
return _validate(committeeId, userOpHash, userOp.signature) ? VALIDATION_SUCCESS : SIG_VALIDATION_FAILED;
}
/**
* @notice ERC-1271 validation: return the magic value iff `signature` carries a valid aggregate
* proof that the account's committee signed `hash`. Called by the account
* (msg.sender == account). Never reverts on a bad or malformed signature.
* @param sender the address that sent the ERC-1271 request to the account (unused here; the
* authority is the committee, not the forwarding sender). [VERIFY] some accounts
* scope by `sender`; this module intentionally does not.
*/
function isValidSignatureWithSender(address sender, bytes32 hash, bytes calldata signature)
external
view
returns (bytes4)
{
sender; // explicitly unused; committee is the sole authority
uint256 committeeId = committeeOf[msg.sender];
if (committeeId == 0) return EIP1271_FAIL;
return _validate(committeeId, hash, signature) ? EIP1271_MAGIC : EIP1271_FAIL;
}
// ==========================================================================
// Internal
// ==========================================================================
/// @dev Parse `signature` as (bytes publicValues, bytes proofBytes) and ask the verifier whether
/// the committee signed `digest`. Never reverts: a malformed signature blob is caught and
/// treated as a validation failure (the ERC-7579 SHOULD-not-revert rule). The verifier's
/// tryVerifyAggregate is itself fail-closed and gas-constant in the committee size n.
function _validate(uint256 committeeId, bytes32 digest, bytes calldata signature)
internal
view
returns (bool)
{
try this.decodeSignature(signature) returns (bytes memory publicValues, bytes memory proofBytes) {
return VERIFIER.tryVerifyAggregate(committeeId, digest, publicValues, proofBytes);
} catch {
return false;
}
}
/// @notice External pure helper so a malformed signature is caught by the internal try/catch
/// instead of reverting validateUserOp. Signature layout: abi.encode(bytes publicValues,
/// bytes proofBytes).
function decodeSignature(bytes calldata signature)
external
pure
returns (bytes memory publicValues, bytes memory proofBytes)
{
(publicValues, proofBytes) = abi.decode(signature, (bytes, bytes));
}
/// @notice Encode a module signature from its parts (off-chain / test helper).
function encodeSignature(bytes calldata publicValues, bytes calldata proofBytes)
external
pure
returns (bytes memory)
{
return abi.encode(publicValues, proofBytes);
}
}