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.
359 lines
20 KiB
Solidity
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 {}
|
|
}
|