// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AerePQCTxAccount, a post-quantum-authorized ERC-4337 smart account (deployable TODAY) * * @notice The deploy-today reference path for AIP-PQ-TX (docs/AIP-PQ-TX-2026-07-18.md). Where that * AIP specifies a NEW base-layer EIP-2718 typed transaction (type 0x2A) whose SENDER * AUTHORIZATION is a NIST post-quantum signature (and which therefore needs a coordinated * Besu hard fork before it can be used), THIS contract delivers the same user-visible * guarantee, "a transaction is authorized by a post-quantum signature and nothing else", * entirely at the account layer, with NO base-layer change. It is an ERC-4337 v0.7 smart * account whose sole owner is a NIST PQC public key, and every fund-moving authorization is * decided by the LIVE native PQC precompiles on AERE mainnet chain 2800 (activated at block * 9,189,161): * * scheme 1 = Falcon-512 verify at 0x0AE1 (NIST round-3 Falcon, 897-byte pk) * scheme 2 = Falcon-1024 verify at 0x0AE2 (NIST round-3 Falcon, 1793-byte pk) * scheme 3 = ML-DSA-44 verify at 0x0AE3 (FIPS 204, 1312-byte pk) * scheme 4 = SLH-DSA-128s verify at 0x0AE4 (FIPS 205 SPHINCS+, 32-byte pk) * * The account calls the precompile DIRECTLY via staticcall, assembling the exact packed * input the live precompiles parse (the byte layout is taken VERBATIM from the mainnet-proven * AerePQCAttestation / AerePQCKeyRegistry, so a positive result here is a real on-chain PQC * verification, not a stand-in). There is no classical ECDSA fallback: the account is * controlled by, and only by, a quantum-resistant key. A userOp can be relayed by any bundler * or any EOA; only the holder of the PQC private key can produce the authorizing signature, * so a relayer only pays gas and can never forge or replay. Because the ECDSA tx sender is * irrelevant to authorization, an adversary who can forge secp256k1 (the exact thing a * quantum computer breaks) still cannot move this account's funds. * * @dev HONEST SCOPE. Application / account layer only. This changes nothing about AERE consensus, * which remains classical ECDSA QBFT (7 Foundation validators, f=2). What is post-quantum * here is the TRANSACTION AUTHORIZATION for this account: a spend is gated on a NIST PQC * signature verified by the live precompiles, not on a secp256k1 key. This is NOT post-quantum * consensus and must never be described as such. The precompiles carry an internal self-audit * only (see AIP-7); an external audit is pending, so this account should not secure material * value until that audit lands. * * @dev SIGNATURE ENVELOPE (identical to the live precompiles / AerePQCAttestation, NOT the * abi.encoded shape AerePQCAccount uses, because this account speaks to the precompile * directly). Wherever a signature is consumed: * Falcon (scheme 1,2): signature = nonce(40) || esig (esig starts 0x29 / 0x2A) * ML-DSA-44 (scheme 3): signature = sig, exactly 2420 bytes * SLH-DSA-128s (scheme 4): signature = sig, exactly 7856 bytes * * @dev DOMAIN SEPARATION (mirrors the adversarial-review HIGH fixed in AerePQCAccount, 2026-07-12). * A fund-moving authorization (validateUserOp / executeWithPQC) is a PQC signature over the * 64-byte message `abi.encodePacked(domain, hash)` (domain = USEROP_DOMAIN / EXEC_DOMAIN), * while the EIP-1271 isValidSignature surface verifies the RAW 32-byte message. A 64-byte * message can never equal a 32-byte message under the precompile's exact-bytes check, so a * signature phished through an EIP-1271 "login" over an attacker-chosen 32-byte hash (which * the attacker could set equal to an execute-challenge or userOpHash, both computable from * public state since execNonce is public) can NEVER be replayed as a spend. * * @dev GAS / EIP-7825. On mainnet the native precompile verify is cheap relative to the * pure-Solidity verifiers: per AIP-7's scratch-fork measurements the marginal verify-op is * ~40,000 gas (Falcon-512), ~75,000 (Falcon-1024), ~55,000 (ML-DSA-44), ~350,000 * (SLH-DSA-128s). A full handleOps -> validateUserOp path is one ordinary transaction that * must stay under the Fusaka EIP-7825 per-transaction cap of 2^24 = 16,777,216 gas; every one * of these schemes leaves ample headroom. isValidSignature is a `view`, so integrators can * verify a PQC signature for free via eth_call regardless of the cap. * * No owner, no admin, no upgrade. The PQC owner key is fixed at construction. Key loss / * rotation is handled by composing at a higher layer (e.g. moving assets to a fresh account, * or an ERC-7579 modular account guarded by AerePQCSocialRecoveryModule), deliberately kept * out of this contract so its authorization surface stays minimal and auditable. */ contract AerePQCTxAccount { // ----- scheme identifiers (match AerePQCAttestation / AerePQCKeyRegistry) ----- uint8 public constant SCHEME_FALCON512 = 1; uint8 public constant SCHEME_FALCON1024 = 2; uint8 public constant SCHEME_MLDSA44 = 3; uint8 public constant SCHEME_SLHDSA128S = 4; // ----- live precompile addresses on chain 2800 (activated block 9,189,161) ----- address internal constant PRECOMPILE_FALCON512 = address(0x0AE1); address internal constant PRECOMPILE_FALCON1024 = address(0x0AE2); address internal constant PRECOMPILE_MLDSA44 = address(0x0AE3); address internal constant PRECOMPILE_SLHDSA128S = address(0x0AE4); // ----- public key sizes / headers ----- uint256 internal constant FALCON512_PK_LEN = 897; uint256 internal constant FALCON1024_PK_LEN = 1793; uint256 internal constant MLDSA44_PK_LEN = 1312; uint256 internal constant SLHDSA128S_PK_LEN = 32; uint8 internal constant FALCON512_PK_HEADER = 0x09; // 0x00 | logn=9 uint8 internal constant FALCON1024_PK_HEADER = 0x0A; // 0x00 | logn=10 // ----- signature sizes (fixed-length schemes) ----- uint256 internal constant MLDSA44_SIG_LEN = 2420; uint256 internal constant SLHDSA128S_SIG_LEN = 7856; uint256 internal constant FALCON_NONCE_LEN = 40; /// @notice Signing-domain separators. A fund-moving authorization is a PQC signature over /// `abi.encodePacked(domain, hash)` (64 bytes); the EIP-1271 surface verifies the raw 32 /// bytes. See the DOMAIN SEPARATION note above. bytes32 public constant USEROP_DOMAIN = keccak256("AerePQCTxAccount.validateUserOp.v1"); bytes32 public constant EXEC_DOMAIN = keccak256("AerePQCTxAccount.executeWithPQC.v1"); /// @notice EIP-1271 magic value returned by isValidSignature on success. bytes4 internal constant EIP1271_MAGIC = 0x1626ba7e; /// @notice ERC-4337 signature-validation-failed sentinel (validationData != 0). uint256 internal constant SIG_VALIDATION_FAILED = 1; /// @notice The NIST PQC scheme (1..4) that owns this account. Fixed at construction. uint8 public immutable pqcScheme; /// @notice ERC-4337 EntryPoint allowed to call validateUserOp + executeFromEntryPoint. Immutable. address public immutable entryPoint; /// @notice The raw NIST PQC public key that owns this account (length/header validated at /// construction against `pqcScheme`). Set once; never mutated. bytes public pqcPubKey; /// @notice Monotonic replay counter for the direct PQC-authorized self-relay path. uint256 public execNonce; /// @notice Generic call struct (used by executeBatchFromEntryPoint). struct Call { address target; uint256 value; bytes data; } /// @notice ERC-4337 v0.7 PackedUserOperation shape (matches AereEntryPointV2 / AerePQCAccount). struct PackedUserOperation { address sender; uint256 nonce; bytes initCode; bytes callData; bytes32 accountGasLimits; uint256 preVerificationGas; bytes32 gasFees; bytes paymasterAndData; bytes signature; } event Initialized(address indexed entryPoint, uint8 indexed scheme, uint256 pubKeyLen); event Executed(uint256 indexed nonce, address indexed target, uint256 value, bytes data); /// @notice Emitted by the EntryPoint-gated batch path. AUD-CONTRACT-3: the `nonce` field carries /// the current self-relay `execNonce`, which the 4337 path does NOT advance, so it is a /// snapshot of the self-relay counter and is NOT a per-batch identifier (two consecutive /// batches emit the same value). Batch authorization is enforced by `msg.sender == /// entryPoint` and userOp ordering by the EntryPoint's own account nonce, never by this /// field; off-chain indexers must not key on it as a unique batch id. event ExecutedBatch(uint256 indexed nonce, uint256 count); error Unauthorized(); error NotInitialized(); error InvalidEntryPoint(); error InvalidScheme(uint8 scheme); error InvalidPqcPubKey(); error InvalidSignature(); error CallFailed(bytes returnData); /// @param _entryPoint the ERC-4337 EntryPoint this account trusts (e.g. AereEntryPointV2 /// 0x8D6f40598d552fF0Cb358b6012cF4227B86aF770 on chain 2800). /// @param _scheme 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s. /// @param _pqcPubKey the raw NIST public key for `_scheme` (length/header validated per scheme). constructor(address _entryPoint, uint8 _scheme, bytes memory _pqcPubKey) { if (_entryPoint == address(0)) revert InvalidEntryPoint(); _validatePubKey(_scheme, _pqcPubKey); entryPoint = _entryPoint; pqcScheme = _scheme; pqcPubKey = _pqcPubKey; emit Initialized(_entryPoint, _scheme, _pqcPubKey.length); } // ─── ERC-4337 path ──────────────────────────────────────────────────────── /// @notice Called by the EntryPoint to validate a user operation. /// @dev Returns 0 on success and SIG_VALIDATION_FAILED (1) on a bad PQC signature, per /// ERC-4337 (does NOT revert on a bad signature). Replay/ordering are enforced by the /// EntryPoint's account nonce: `userOpHash` already binds sender + chainid + nonce, and /// the signature is domain-separated so it cannot be replayed on the exec or EIP-1271 /// surfaces. function validateUserOp( PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds ) external returns (uint256 validationData) { if (msg.sender != entryPoint) revert Unauthorized(); if (!_verifyDomained(USEROP_DOMAIN, userOpHash, userOp.signature)) { return SIG_VALIDATION_FAILED; } // Pay the EntryPoint any prefund it needs (the EntryPoint enforces the gas budget). if (missingAccountFunds > 0) { (bool sent, ) = payable(entryPoint).call{value: missingAccountFunds}(""); (sent); // ignore — EntryPoint reverts if underfunded } return 0; } /// @notice Called by the EntryPoint after validation to dispatch the user's call. 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); } /// @notice Batch variant of executeFromEntryPoint. function executeBatchFromEntryPoint(Call[] calldata calls) external { if (msg.sender != entryPoint) revert Unauthorized(); for (uint256 i = 0; i < calls.length; i++) { (bool ok, bytes memory ret) = calls[i].target.call{value: calls[i].value}(calls[i].data); if (!ok) revert CallFailed(ret); } // AUD-CONTRACT-3: `execNonce` is the self-relay replay counter and is intentionally NOT // advanced on the 4337 path (replay/ordering is the EntryPoint's account nonce). The logged // value is therefore a snapshot, not a per-batch id; see the ExecutedBatch NatSpec. emit ExecutedBatch(execNonce, calls.length); } // ─── Direct PQC-authorized path (self-relay, no bundler required) ────────── /// @notice The 32-byte challenge the owner's PQC key must sign for an executeWithPQC() call. /// Binds this account + chainid + the current execNonce + the call, so a leg is scoped to /// one account, one chain, one nonce, and one exact call. function getExecuteChallenge( address target, uint256 value, bytes calldata data, uint256 expectedNonce ) public view returns (bytes32) { return keccak256(abi.encode(address(this), block.chainid, expectedNonce, target, value, data)); } /// @notice Execute a single call, authorized by a PQC signature over the current execute-challenge. /// Lets the account be driven directly (e.g. by a Foundation relayer) without routing /// through a 4337 bundler. Domain-separated (EXEC_DOMAIN): a signature phished via the /// raw-hash isValidSignature surface cannot authorize a spend here. function executeWithPQC( bytes calldata signature, address target, uint256 value, bytes calldata data ) external returns (bytes memory result) { bytes32 challenge = getExecuteChallenge(target, value, data, execNonce); if (!_verifyDomained(EXEC_DOMAIN, challenge, signature)) revert InvalidSignature(); uint256 n = execNonce; execNonce = n + 1; (bool ok, bytes memory ret) = target.call{value: value}(data); if (!ok) revert CallFailed(ret); emit Executed(n, target, value, data); return ret; } // ─── EIP-1271 ───────────────────────────────────────────────────────────── /// @notice EIP-1271: returns the magic value iff `signature` is a valid PQC signature over the /// RAW 32 bytes of `hash` under this account's owner key. A `view`, so integrators /// (Permit2, OpenSea, Snapshot, dapp logins) can verify a post-quantum signature for free /// via eth_call. The raw-hash message is length-distinct from the 64-byte fund-moving /// messages, so a login signature can never authorize a spend. function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { return _verify(abi.encodePacked(hash), signature) ? EIP1271_MAGIC : bytes4(0); } // ─── PQC verification (internal) ────────────────────────────────────────── /// @dev Domain-separated PQC verify for the fund-moving paths: verifies over the 64-byte message /// `abi.encodePacked(domain, hash)`. function _verifyDomained(bytes32 domain, bytes32 hash, bytes calldata signature) internal view returns (bool) { return _verify(abi.encodePacked(domain, hash), signature); } /// @dev Build the exact precompile input for this account's scheme with `message` as the signed /// message, staticcall the live precompile, and return true iff it answers 0x..01. Fail- /// closed: any malformed length, staticcall failure, or empty/short/zero return is invalid /// (never reverts), so a bad signature yields `false` rather than a revert (required by /// validateUserOp). function _verify(bytes memory message, bytes calldata signature) internal view returns (bool) { (bool built, address precompileAddr, bytes memory input) = _buildInput(message, signature); if (!built) return false; (bool ok, bytes memory ret) = precompileAddr.staticcall(input); return ok && ret.length >= 32 && ret[31] == 0x01; } /// @dev Assemble (precompile, input) for this account's scheme with `message` as the signed /// message. Byte layout is identical to AerePQCAttestation._buildInput (proven against the /// live mainnet precompiles), generalised so `message` may be any length (32 or 64 bytes /// here). Returns built=false on any length that cannot form a valid input. function _buildInput(bytes memory message, bytes calldata signature) internal view returns (bool built, address precompileAddr, bytes memory input) { uint8 scheme = pqcScheme; bytes memory pk = pqcPubKey; // load the owner key once into memory if (scheme == SCHEME_FALCON512 || scheme == SCHEME_FALCON1024) { // signature = nonce(40) || esig if (signature.length <= FALCON_NONCE_LEN) return (false, address(0), ""); uint256 sigLen = signature.length - FALCON_NONCE_LEN; // esig length if (sigLen > type(uint16).max) return (false, address(0), ""); bytes memory nonce = signature[0:FALCON_NONCE_LEN]; bytes memory esig = signature[FALCON_NONCE_LEN:]; // sm = sigLen(2, big-endian) || nonce(40) || message || esig bytes memory sm = abi.encodePacked(uint16(sigLen), nonce, message, esig); input = abi.encodePacked(pk, sm); precompileAddr = scheme == SCHEME_FALCON512 ? PRECOMPILE_FALCON512 : PRECOMPILE_FALCON1024; } else if (scheme == SCHEME_MLDSA44) { if (signature.length != MLDSA44_SIG_LEN) return (false, address(0), ""); input = abi.encodePacked(pk, signature, message); // pk(1312) || sig(2420) || message precompileAddr = PRECOMPILE_MLDSA44; } else if (scheme == SCHEME_SLHDSA128S) { if (signature.length != SLHDSA128S_SIG_LEN) return (false, address(0), ""); input = abi.encodePacked(pk, signature, message); // pk(32) || sig(7856) || message precompileAddr = PRECOMPILE_SLHDSA128S; } else { return (false, address(0), ""); } built = true; } function _validatePubKey(uint8 scheme, bytes memory pubKey) internal pure { if (scheme == SCHEME_FALCON512) { if (pubKey.length != FALCON512_PK_LEN || uint8(pubKey[0]) != FALCON512_PK_HEADER) revert InvalidPqcPubKey(); } else if (scheme == SCHEME_FALCON1024) { if (pubKey.length != FALCON1024_PK_LEN || uint8(pubKey[0]) != FALCON1024_PK_HEADER) revert InvalidPqcPubKey(); } else if (scheme == SCHEME_MLDSA44) { if (pubKey.length != MLDSA44_PK_LEN) revert InvalidPqcPubKey(); } else if (scheme == SCHEME_SLHDSA128S) { if (pubKey.length != SLHDSA128S_PK_LEN) revert InvalidPqcPubKey(); } else { revert InvalidScheme(scheme); } } // ─── Views ──────────────────────────────────────────────────────────────── /// @notice The live precompile address this account's scheme verifies through. function precompileAddress() external view returns (address) { if (pqcScheme == SCHEME_FALCON512) return PRECOMPILE_FALCON512; if (pqcScheme == SCHEME_FALCON1024) return PRECOMPILE_FALCON1024; if (pqcScheme == SCHEME_MLDSA44) return PRECOMPILE_MLDSA44; return PRECOMPILE_SLHDSA128S; } /// @notice The exact 64-byte message the owner PQC key must sign to authorize a userOp with hash /// `userOpHash`. Off-chain signers query this to know what to sign. function userOpSigningMessage(bytes32 userOpHash) external pure returns (bytes memory) { return abi.encodePacked(USEROP_DOMAIN, userOpHash); } receive() external payable {} }