aere-contracts/contracts/mpc/AereThresholdAccount.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

258 lines
14 KiB
Solidity

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity 0.8.23;
import {AerePQCThreshold} from "./AerePQCThreshold.sol";
/// @title AereThresholdAccount
/// @notice A non-custodial ERC-4337 (v0.7) smart account whose owner is a t-of-n POST-QUANTUM
/// committee: a user operation is authorized iff at least `threshold` DISTINCT committee
/// members each supply a valid NIST PQC signature (Falcon / ML-DSA / SLH-DSA) over the
/// account's domain-separated challenge, every leg verified in full, on-chain, by AERE's
/// LIVE precompiles (0x0AE1..0x0AE4). No single party's key can move funds; there is no
/// admin, owner override, or recovery backdoor. Built for non-custodial custody (e.g. a
/// crypto-card neobank) where quantum-durable, distributed key control is required.
///
/// @dev Two independent, DOMAIN-SEPARATED authorization paths, each with its own replay
/// barrier (so a signature made for one path can never be replayed on the other):
/// 1. ERC-4337 `validateUserOp` — challenge binds `userOpHash` (whose EntryPoint nonce
/// is the replay barrier). It reads only this account's own storage and staticcalls the
/// precompiles, so it satisfies the bundler's validation storage rules. It does not
/// mutate persistent account storage during validation, but per ERC-4337 it MAY emit a
/// LOG and, when the EntryPoint requests it, transfer the missing prefund to the
/// EntryPoint; it is therefore NOT declared `view`.
/// 2. Direct self-relay `executeThreshold` — no bundler required; challenge binds a
/// per-account `execNonce` + the exact call, which increments on success.
/// The committee (scheme, threshold, member public keys) is stored in THIS account's own
/// storage, set once by the factory, immutable thereafter. Application/account layer only;
/// AERE consensus remains classical ECDSA QBFT.
contract AereThresholdAccount {
using AerePQCThreshold for uint8;
// ─── config (set once by the factory) ─────────────────────────────────────
address public immutable factory;
address public entryPoint;
uint8 public scheme; // 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s
uint8 public threshold; // t
uint8 public size; // n
bytes32 public membersHash; // keccak256(abi.encode(pubKeys)) — pins the exact key set
bytes[] internal _pubKeys;
uint64 public execNonce; // replay barrier for the direct self-relay path
/// @notice ERC-4337 v0.7 PackedUserOperation shape (matches AereEntryPointV2).
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
/// @notice One authorizing leg: a committee member index + that member's PQC signature over
/// the account challenge, in the scheme's on-chain envelope.
struct Leg {
uint8 memberIndex;
bytes signature;
}
/// @notice ERC-4337 signature-validation-failed sentinel (validationData != 0).
uint256 internal constant SIG_VALIDATION_FAILED = 1;
// Distinct domain separators — a signature for one path never verifies on the other.
bytes32 public constant USEROP_DOMAIN = keccak256("AereThresholdAccount.v1.userop");
bytes32 public constant EXEC_DOMAIN = keccak256("AereThresholdAccount.v1.exec");
event Initialized(address indexed entryPoint, uint8 scheme, uint8 threshold, uint8 size, bytes32 membersHash);
event Executed(uint64 indexed execNonce, address indexed target, uint256 value, uint8 signers);
event UserOpAuthorized(bytes32 indexed userOpHash, uint8 signers);
error Unauthorized();
error AlreadyInitialized();
error BadParams();
error InvalidPubKey(uint8 memberIndex);
error DuplicatePubKey(uint8 memberIndex);
error BelowThreshold(uint256 got, uint256 need);
error CallFailed(bytes returnData);
constructor() {
factory = msg.sender;
}
/// @notice One-shot initializer, callable only by the deploying factory.
/// @param _entryPoint the ERC-4337 EntryPoint the account trusts for the UserOp path.
/// @param _scheme the committee's shared NIST PQC scheme (1..4).
/// @param _threshold t: minimum distinct valid signers to authorize (1 <= t <= n).
/// @param pubKeys the n member public keys (each validated for length + header).
function initialize(address _entryPoint, uint8 _scheme, uint8 _threshold, bytes[] calldata pubKeys) external {
if (msg.sender != factory) revert Unauthorized();
if (entryPoint != address(0) || size != 0) revert AlreadyInitialized();
uint256 n = pubKeys.length;
if (_entryPoint == address(0) || n < 1 || n > 255 || _threshold < 1 || _threshold > n) revert BadParams();
// Enforce DISTINCT committee keys. Distinctness is counted by member INDEX at authorization
// time (the `seen` bitmask in _countMem), so if the SAME public key occupied two indices, a
// single keyholder could fill multiple "distinct member" slots and reach `threshold` alone -
// silently collapsing the t-of-n non-custodial guarantee (the whole point of this account).
// Reject any duplicate here, at the one place the committee is ever set. Keys are hashed once
// (n keccaks) then compared pairwise on the 32-byte hashes; n <= 255, a one-time init cost.
bytes32[] memory keyHashes = new bytes32[](n);
for (uint256 i = 0; i < n; i++) {
if (!AerePQCThreshold.validPubKey(_scheme, pubKeys[i])) revert InvalidPubKey(uint8(i));
bytes32 kh = keccak256(pubKeys[i]);
for (uint256 j = 0; j < i; j++) {
if (keyHashes[j] == kh) revert DuplicatePubKey(uint8(i));
}
keyHashes[i] = kh;
_pubKeys.push(pubKeys[i]);
}
entryPoint = _entryPoint;
scheme = _scheme;
threshold = _threshold;
size = uint8(n);
membersHash = keccak256(abi.encode(pubKeys));
emit Initialized(_entryPoint, _scheme, _threshold, uint8(n), membersHash);
}
// ─── ERC-4337 path (bundler) ──────────────────────────────────────────────
/// @notice Called by the EntryPoint to validate a user operation. Returns 0 iff >= threshold
/// DISTINCT committee members validly PQC-signed this account's userOp challenge;
/// SIG_VALIDATION_FAILED otherwise. Never reverts on a bad OR non-decodable signature
/// blob (both return SIG_VALIDATION_FAILED). Does not mutate persistent account storage
/// during validation; per ERC-4337 it may emit a LOG and may transfer the requested
/// prefund to the EntryPoint (so it is not `view`).
function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
external
returns (uint256 validationData)
{
if (msg.sender != entryPoint) revert Unauthorized();
bytes32 challenge = userOpChallenge(userOpHash);
(bool ok, uint256 signers) = _countThreshold(challenge, userOp.signature);
if (!ok) return SIG_VALIDATION_FAILED;
emit UserOpAuthorized(userOpHash, uint8(signers));
if (missingAccountFunds > 0) {
(bool sent, ) = payable(entryPoint).call{value: missingAccountFunds}("");
(sent); // EntryPoint reverts if underfunded
}
return 0;
}
/// @notice Dispatch a call after the EntryPoint has validated the UserOp.
function executeFromEntryPoint(address target, uint256 value, bytes calldata data) external {
if (msg.sender != entryPoint) revert Unauthorized();
(bool ok, bytes memory ret) = target.call{value: value}(data);
if (!ok) revert CallFailed(ret);
}
// ─── Direct self-relay path (no bundler) ──────────────────────────────────
/// @notice Authorize and execute a single call directly, without a bundler: `legs` must carry
/// >= threshold distinct valid PQC signatures over the exec challenge binding the
/// current `execNonce` and the exact call. Reverts below threshold. `execNonce`
/// increments on success so the same legs can never be replayed.
function executeThreshold(address target, uint256 value, bytes calldata data, Leg[] calldata legs)
external
returns (uint64 usedNonce)
{
usedNonce = execNonce;
bytes32 challenge = execChallenge(usedNonce, target, value, data);
(bool ok, uint256 signers) = _countThresholdLegs(challenge, legs);
if (!ok) revert BelowThreshold(signers, threshold);
execNonce = usedNonce + 1; // effects before interaction
emit Executed(usedNonce, target, value, uint8(signers));
(bool s, bytes memory ret) = target.call{value: value}(data);
if (!s) revert CallFailed(ret);
}
// ─── threshold counting (shared, VIEW) ────────────────────────────────────
/// @dev Count DISTINCT valid signers over `challenge` from an abi-encoded Leg[] blob. Only a
/// leg that is in-range, not-yet-seen, and verifies is counted; malformed/duplicate/
/// invalid legs are ignored (an attacker can never inflate the distinct-valid count).
function _countThreshold(bytes32 challenge, bytes calldata sigBlob) internal view returns (bool, uint256) {
// AUD-CONTRACT-1: `abi.decode` REVERTS on a non-decodable blob (empty, truncated, bad
// offsets). ERC-4337 v0.7 requires signature errors to be signalled by return value, not by
// a revert escaping validateUserOp. Decode via an external self-call wrapped in try/catch so
// a malformed blob yields (false, 0) => SIG_VALIDATION_FAILED, mirroring the fail-soft
// `AerePQCTxAccount._buildInput`. `decodeLegs` is `pure`, so this compiles to a STATICCALL to
// self and stays view-safe for bundler simulation (the decode touches no storage).
try this.decodeLegs(sigBlob) returns (Leg[] memory legs) {
return _countMem(challenge, legs);
} catch {
return (false, 0);
}
}
/// @notice External ABI-decode helper for `_countThreshold`'s try/catch. Pure (reads only its
/// calldata argument); reverts iff `sigBlob` is not a well-formed `Leg[]` encoding.
/// @dev Exposed only so the internal counter can CATCH a non-decodable signature blob and
/// return SIG_VALIDATION_FAILED instead of reverting; it carries no authority and mutates
/// nothing.
function decodeLegs(bytes calldata sigBlob) external pure returns (Leg[] memory) {
return abi.decode(sigBlob, (Leg[]));
}
function _countThresholdLegs(bytes32 challenge, Leg[] calldata legs) internal view returns (bool, uint256) {
// copy calldata legs to memory once for the shared counter
Leg[] memory m = new Leg[](legs.length);
for (uint256 i = 0; i < legs.length; i++) {
m[i] = Leg({memberIndex: legs[i].memberIndex, signature: legs[i].signature});
}
return _countMem(challenge, m);
}
function _countMem(bytes32 challenge, Leg[] memory legs) internal view returns (bool, uint256) {
uint256 seen = 0;
uint256 valid = 0;
uint8 n = size;
uint8 s = scheme;
for (uint256 i = 0; i < legs.length; i++) {
uint8 idx = legs[i].memberIndex;
if (idx >= n) continue;
uint256 bit = uint256(1) << idx;
if (seen & bit != 0) continue; // never double-count a member
if (AerePQCThreshold.verify(s, _pubKeys[idx], challenge, legs[i].signature)) {
seen |= bit;
valid += 1;
}
}
return (valid >= threshold, valid);
}
// ─── challenge helpers (off-chain signers query these) ────────────────────
/// @notice The exact 32-byte challenge each member must PQC-sign to authorize a UserOp.
function userOpChallenge(bytes32 userOpHash) public view returns (bytes32) {
return keccak256(abi.encode(USEROP_DOMAIN, block.chainid, address(this), userOpHash));
}
/// @notice The exact 32-byte challenge each member must PQC-sign to authorize a direct call
/// at `nonce` (bind the current `execNonce`).
function execChallenge(uint64 nonce, address target, uint256 value, bytes calldata data)
public
view
returns (bytes32)
{
return keccak256(abi.encode(EXEC_DOMAIN, block.chainid, address(this), nonce, target, value, keccak256(data)));
}
// ─── views ────────────────────────────────────────────────────────────────
function pubKeyAt(uint8 memberIndex) external view returns (bytes memory) {
return _pubKeys[memberIndex];
}
function committee() external view returns (uint8 _scheme, uint8 _threshold, uint8 _size, bytes32 _membersHash) {
return (scheme, threshold, size, membersHash);
}
receive() external payable {}
}