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.
346 lines
14 KiB
Solidity
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 {}
|
|
}
|