aere-contracts/contracts/pqc/AerePQCTxAccount.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

359 lines
20 KiB
Solidity

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