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.
258 lines
14 KiB
Solidity
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 {}
|
|
}
|