aere-contracts/contracts/modular/AereModularAccount.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

346 lines
14 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
/// @notice Minimal ERC-7579-style module interface used by AereModularAccount.
interface IAereModule {
function onInstall(bytes calldata data) external;
function onUninstall(bytes calldata data) external;
function isModuleType(uint256 moduleTypeId) external view returns (bool);
}
/// @notice Validator module (module type 1) interface.
/// @dev Adapted from ERC-7579: the account strips its routing prefix and passes
/// the inner signature explicitly, together with the userOp callData so the
/// validator can scope what the op is allowed to do.
interface IAereValidator is IAereModule {
function validateUserOp(
bytes32 userOpHash,
bytes calldata accountCallData,
bytes calldata signature
) external returns (uint256 validationData);
function isValidSignatureWithSender(
address sender,
bytes32 hash,
bytes calldata signature
) external view returns (bytes4);
}
/// @notice Deposit-view subset of AereEntryPointV2.
interface IAereEntryPointDeposits {
function balanceOf(address account) external view returns (uint256);
}
/// @title AereModularAccount - ERC-4337 smart account with ERC-7579-style modules
/// @notice Targets AERE's own AereEntryPointV2 (0x8D6f40598d552fF0Cb358b6012cF4227B86aF770,
/// chain 2800) EXACTLY:
/// - The EntryPoint keeps a strictly sequential per-sender nonce in its own
/// storage (no 2D nonce keys), so validator ROUTING CANNOT use a nonce key.
/// ROUTING CHOICE (documented per spec): the FIRST SIGNATURE BYTE routes:
/// 0x00 -> root-owner ECDSA fallback. Layout: 0x00 || 65-byte ECDSA sig
/// (EIP-191 personal_sign envelope over userOpHash, matching the
/// AerePasskeyAccountV2 repo convention).
/// 0x01 -> installed validator module. Layout:
/// 0x01 || 20-byte validator address || validator payload.
/// - AereEntryPointV2 REVERTS on ANY non-zero return from validateUserOp
/// (it does not unpack 4337 time bounds). Validators therefore return the
/// CANONICAL 4337 packing (authorizer | validUntil<<160 | validAfter<<208)
/// and THIS ACCOUNT unpacks it, enforces the time window against
/// block.timestamp itself, and returns strictly 0 (valid) or 1 (invalid).
/// - The EntryPoint executes ops by calling sender.call(op.callData), so
/// userOp.callData must encode execute()/executeBatch() on this account.
/// - Gas is charged from the account's EntryPoint deposit; validateUserOp
/// tops up only the shortfall from the account balance.
/// @dev Module types: 1 = validator (signature validation), 2 = executor (may call
/// executeFromExecutor and setRootOwner, e.g. the social recovery module).
contract AereModularAccount {
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
struct Call {
address target;
uint256 value;
bytes data;
}
uint256 public constant MODULE_TYPE_VALIDATOR = 1;
uint256 public constant MODULE_TYPE_EXECUTOR = 2;
bytes4 internal constant EIP1271_MAGIC = 0x1626ba7e;
bytes4 internal constant EIP1271_FAIL = 0xffffffff;
/// @notice ERC-4337 EntryPoint allowed to call validateUserOp/execute. Set once.
address public entryPoint;
/// @notice Root owner EOA: fallback validation + direct admin. Replaceable by
/// self-call or an installed executor module (social recovery).
address public rootOwner;
/// @notice Factory that deployed this account (gates initialize).
address public immutable factory;
/// @dev moduleTypeId => module => installed.
mapping(uint256 => mapping(address => bool)) internal _installed;
event ModuleInstalled(uint256 indexed moduleTypeId, address indexed module);
event ModuleUninstalled(uint256 indexed moduleTypeId, address indexed module);
event RootOwnerChanged(address indexed previousOwner, address indexed newOwner);
event Executed(address indexed target, uint256 value, bytes data);
event ExecutedBatch(uint256 count);
error Unauthorized();
error AlreadyInitialized();
error InvalidEntryPoint();
error InvalidRootOwner();
error UnsupportedModuleType(uint256 moduleTypeId);
error InvalidModule(address module);
error ModuleAlreadyInstalled(address module);
error ModuleNotInstalled(address module);
error CallFailed(bytes returnData);
/// @dev Admin gate. NOTE: in addition to the EntryPoint and self-calls, the
/// current root owner may call admin functions DIRECTLY (documented
/// convenience so module management does not require a userOp round-trip;
/// the root owner already has full authority via the 0x00 signature path).
modifier onlyEntryPointSelfOrRoot() {
if (msg.sender != entryPoint && msg.sender != address(this) && msg.sender != rootOwner) {
revert Unauthorized();
}
_;
}
constructor() {
factory = msg.sender;
}
/// @notice One-shot initializer called by the factory at deploy time.
function initialize(address _entryPoint, address _rootOwner) external {
if (msg.sender != factory) revert Unauthorized();
if (entryPoint != address(0)) revert AlreadyInitialized();
if (_entryPoint == address(0)) revert InvalidEntryPoint();
if (_rootOwner == address(0)) revert InvalidRootOwner();
entryPoint = _entryPoint;
rootOwner = _rootOwner;
emit RootOwnerChanged(address(0), _rootOwner);
}
// ---- ERC-7579-style module management ------------------------------------
function installModule(uint256 moduleTypeId, address module, bytes calldata initData)
external
onlyEntryPointSelfOrRoot
{
if (moduleTypeId != MODULE_TYPE_VALIDATOR && moduleTypeId != MODULE_TYPE_EXECUTOR) {
revert UnsupportedModuleType(moduleTypeId);
}
if (module.code.length == 0) revert InvalidModule(module);
if (_installed[moduleTypeId][module]) revert ModuleAlreadyInstalled(module);
if (!IAereModule(module).isModuleType(moduleTypeId)) revert InvalidModule(module);
_installed[moduleTypeId][module] = true;
IAereModule(module).onInstall(initData);
emit ModuleInstalled(moduleTypeId, module);
}
function uninstallModule(uint256 moduleTypeId, address module, bytes calldata deInitData)
external
onlyEntryPointSelfOrRoot
{
if (!_installed[moduleTypeId][module]) revert ModuleNotInstalled(module);
_installed[moduleTypeId][module] = false;
// Best effort deinit: a reverting module must never brick uninstall.
(bool ok, ) = module.call(abi.encodeWithSelector(IAereModule.onUninstall.selector, deInitData));
(ok);
emit ModuleUninstalled(moduleTypeId, module);
}
function isModuleInstalled(uint256 moduleTypeId, address module, bytes calldata)
external
view
returns (bool)
{
return _installed[moduleTypeId][module];
}
function supportsModule(uint256 moduleTypeId) external pure returns (bool) {
return moduleTypeId == MODULE_TYPE_VALIDATOR || moduleTypeId == MODULE_TYPE_EXECUTOR;
}
function accountId() external pure returns (string memory) {
return "aere.modular-account.1.0.0";
}
// ---- ERC-4337 validation ---------------------------------------------------
/// @notice Called by AereEntryPointV2. Returns strictly 0 (valid) or 1 (invalid)
/// because this EntryPoint reverts on any non-zero validationData.
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
if (msg.sender != entryPoint) revert Unauthorized();
validationData = _validateSignature(userOp, userOpHash);
if (validationData == 0) {
_payPrefund(missingAccountFunds);
}
}
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
returns (uint256)
{
bytes calldata sig = userOp.signature;
if (sig.length == 0) return 1;
uint8 prefix = uint8(sig[0]);
if (prefix == 0x00) {
// Root-owner fallback: 0x00 || 65-byte ECDSA over EIP-191(userOpHash).
if (sig.length != 66) return 1;
(address rec, ECDSA.RecoverError err) =
ECDSA.tryRecover(ECDSA.toEthSignedMessageHash(userOpHash), sig[1:66]);
if (err != ECDSA.RecoverError.NoError || rec != rootOwner) return 1;
return 0;
}
if (prefix == 0x01) {
// Validator route: 0x01 || 20-byte validator address || payload.
if (sig.length < 21) return 1;
address validator = address(bytes20(sig[1:21]));
if (!_installed[MODULE_TYPE_VALIDATOR][validator]) return 1;
uint256 vd = IAereValidator(validator).validateUserOp(userOpHash, userOp.callData, sig[21:]);
// Canonical 4337 packing from the validator:
// authorizer (160 bits) | validUntil (48 bits) << 160 | validAfter (48 bits) << 208
// AereEntryPointV2 cannot unpack this, so enforce locally.
if (address(uint160(vd)) != address(0)) return 1; // sig failure / no aggregator support
uint48 validUntil = uint48(vd >> 160);
uint48 validAfter = uint48(vd >> 208);
if (validAfter != 0 && block.timestamp < validAfter) return 1;
if (validUntil != 0 && block.timestamp > validUntil) return 1;
return 0;
}
return 1;
}
/// @dev AereEntryPointV2 passes maxCost and later charges the account's EP
/// deposit for actual gas. Top up only the shortfall from our balance.
function _payPrefund(uint256 missingAccountFunds) internal {
if (missingAccountFunds == 0) return;
uint256 dep = IAereEntryPointDeposits(entryPoint).balanceOf(address(this));
if (dep >= missingAccountFunds) return;
uint256 need = missingAccountFunds - dep;
if (need > address(this).balance) need = address(this).balance;
if (need == 0) return;
// EntryPoint.receive() credits our own deposit (depositTo(msg.sender)).
(bool ok, ) = payable(entryPoint).call{value: need}("");
(ok); // EntryPoint reverts later if still underfunded
}
// ---- Execution ---------------------------------------------------------------
function execute(address target, uint256 value, bytes calldata data)
external
onlyEntryPointSelfOrRoot
returns (bytes memory result)
{
result = _call(target, value, data);
emit Executed(target, value, data);
}
function executeBatch(Call[] calldata calls)
external
onlyEntryPointSelfOrRoot
returns (bytes[] memory results)
{
results = new bytes[](calls.length);
for (uint256 i = 0; i < calls.length; i++) {
results[i] = _call(calls[i].target, calls[i].value, calls[i].data);
}
emit ExecutedBatch(calls.length);
}
/// @notice Execution entry for installed executor modules (module type 2).
function executeFromExecutor(address target, uint256 value, bytes calldata data)
external
returns (bytes memory result)
{
if (!_installed[MODULE_TYPE_EXECUTOR][msg.sender]) revert Unauthorized();
result = _call(target, value, data);
emit Executed(target, value, data);
}
/// @notice Replace the root owner. Callable by a self-call (root rotating its
/// own key via execute) or by an installed executor module (social
/// recovery after guardian approval + timelock).
function setRootOwner(address newOwner) external {
if (msg.sender != address(this) && !_installed[MODULE_TYPE_EXECUTOR][msg.sender]) {
revert Unauthorized();
}
if (newOwner == address(0)) revert InvalidRootOwner();
emit RootOwnerChanged(rootOwner, newOwner);
rootOwner = newOwner;
}
function _call(address target, uint256 value, bytes calldata data)
internal
returns (bytes memory)
{
(bool ok, bytes memory ret) = target.call{value: value}(data);
if (!ok) revert CallFailed(ret);
return ret;
}
// ---- EIP-1271 ------------------------------------------------------------------
/// @notice EIP-1271 validation, routed like userOp signatures:
/// 0x00 || 65-byte root ECDSA, or 0x01 || validator || payload.
/// The hash is domain-wrapped (account, chainid) to prevent cross-account
/// replay, matching the AerePasskeyAccountV2 repo convention.
function isValidSignature(bytes32 hash, bytes calldata signature)
external
view
returns (bytes4)
{
if (signature.length == 0) return EIP1271_FAIL;
uint8 prefix = uint8(signature[0]);
bytes32 wrapped = _wrap1271(hash);
if (prefix == 0x00) {
if (signature.length != 66) return EIP1271_FAIL;
(address rec, ECDSA.RecoverError err) =
ECDSA.tryRecover(ECDSA.toEthSignedMessageHash(wrapped), signature[1:66]);
if (err != ECDSA.RecoverError.NoError || rec != rootOwner) return EIP1271_FAIL;
return EIP1271_MAGIC;
}
if (prefix == 0x01 && signature.length >= 21) {
address validator = address(bytes20(signature[1:21]));
if (!_installed[MODULE_TYPE_VALIDATOR][validator]) return EIP1271_FAIL;
return IAereValidator(validator).isValidSignatureWithSender(msg.sender, wrapped, signature[21:]);
}
return EIP1271_FAIL;
}
function _wrap1271(bytes32 hash) internal view returns (bytes32) {
return keccak256(abi.encode(
keccak256("AereAccount1271(address account,uint256 chainid,bytes32 hash)"),
address(this),
block.chainid,
hash
));
}
receive() external payable {}
}