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