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