// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// @notice Root-owner surface of AereModularAccount used by this module. interface IAereModularAccountOwner { function rootOwner() external view returns (address); function setRootOwner(address newOwner) external; } /// @title AereSocialRecoveryModule - M-of-N guardian recovery for AereModularAccount /// @notice Singleton executor module (ERC-7579 module type 2). Per-account state is /// keyed by the account address. Install this module on the account as /// module type 2; executeRecovery then calls account.setRootOwner(), which /// AereModularAccount permits for installed executor modules. /// /// Flow: guardian initiateRecovery -> other guardians supportRecovery -> /// after a 48 hour timelock AND >= threshold approvals, any guardian may /// executeRecovery. The current owner (or the account itself) can /// cancelRecovery at any time before execution. /// /// Guardian set and threshold changes are OWNER-ONLY: mutating config /// functions require msg.sender == the account, and the account's /// execute() path is gated to EntryPoint / self / root owner. contract AereSocialRecoveryModule { uint256 public constant RECOVERY_DELAY = 48 hours; struct Recovery { address newOwner; uint64 executeAfter; // initiation time + RECOVERY_DELAY uint32 approvals; bool active; } /// @notice account => guardian => is guardian. mapping(address => mapping(address => bool)) public isGuardian; /// @notice account => guardian list (for views + uninstall cleanup). mapping(address => address[]) internal _guardianList; /// @notice account => M in M-of-N. mapping(address => uint256) public thresholdOf; /// @notice account => monotonically increasing recovery round id. mapping(address => uint256) public recoveryNonce; /// @notice account => currently active recovery (if any). mapping(address => Recovery) public activeRecovery; /// @notice account => round id => guardian => approved. mapping(address => mapping(uint256 => mapping(address => bool))) public hasApproved; event GuardianAdded(address indexed account, address indexed guardian); event GuardianRemoved(address indexed account, address indexed guardian); event ThresholdChanged(address indexed account, uint256 threshold); event RecoveryInitiated(address indexed account, uint256 indexed round, address indexed newOwner, address guardian, uint64 executeAfter); event RecoverySupported(address indexed account, uint256 indexed round, address indexed guardian, uint256 approvals); event RecoveryExecuted(address indexed account, uint256 indexed round, address indexed newOwner); event RecoveryCancelled(address indexed account, uint256 indexed round, address canceller); error NotGuardian(); error NotOwner(); error InvalidGuardian(); error InvalidThreshold(); error InvalidNewOwner(); error RecoveryAlreadyActive(); error NoActiveRecovery(); error AlreadyApproved(); error BelowThreshold(); error TimelockNotElapsed(uint64 executeAfter, uint256 nowTimestamp); // ---- ERC-7579-style module base ------------------------------------------- function isModuleType(uint256 moduleTypeId) external pure returns (bool) { return moduleTypeId == 2; } /// @notice initData layout: abi.encode(address[] guardians, uint256 threshold). function onInstall(bytes calldata data) external { (address[] memory guardians, uint256 threshold) = abi.decode(data, (address[], uint256)); address account = msg.sender; for (uint256 i = 0; i < guardians.length; i++) { _addGuardian(account, guardians[i]); } if (threshold == 0 || threshold > _guardianList[account].length) revert InvalidThreshold(); thresholdOf[account] = threshold; emit ThresholdChanged(account, threshold); } /// @notice Full per-account cleanup so a later reinstall starts clean. function onUninstall(bytes calldata) external { address account = msg.sender; address[] storage list = _guardianList[account]; for (uint256 i = 0; i < list.length; i++) { isGuardian[account][list[i]] = false; } delete _guardianList[account]; delete thresholdOf[account]; delete activeRecovery[account]; } // ---- Owner-only guardian management (msg.sender = the smart account) ------ function addGuardian(address guardian) external { _addGuardian(msg.sender, guardian); } function removeGuardian(address guardian) external { address account = msg.sender; if (!isGuardian[account][guardian]) revert InvalidGuardian(); isGuardian[account][guardian] = false; address[] storage list = _guardianList[account]; for (uint256 i = 0; i < list.length; i++) { if (list[i] == guardian) { list[i] = list[list.length - 1]; list.pop(); break; } } if (thresholdOf[account] > list.length) revert InvalidThreshold(); emit GuardianRemoved(account, guardian); } function setThreshold(uint256 threshold) external { address account = msg.sender; if (threshold == 0 || threshold > _guardianList[account].length) revert InvalidThreshold(); thresholdOf[account] = threshold; emit ThresholdChanged(account, threshold); } function guardiansOf(address account) external view returns (address[] memory) { return _guardianList[account]; } function _addGuardian(address account, address guardian) internal { if (guardian == address(0) || guardian == account) revert InvalidGuardian(); if (isGuardian[account][guardian]) revert InvalidGuardian(); isGuardian[account][guardian] = true; _guardianList[account].push(guardian); emit GuardianAdded(account, guardian); } // ---- Recovery flow ---------------------------------------------------------- function initiateRecovery(address account, address newOwner) external { if (!isGuardian[account][msg.sender]) revert NotGuardian(); if (newOwner == address(0)) revert InvalidNewOwner(); if (activeRecovery[account].active) revert RecoveryAlreadyActive(); uint256 round = ++recoveryNonce[account]; uint64 executeAfter = uint64(block.timestamp + RECOVERY_DELAY); activeRecovery[account] = Recovery({ newOwner: newOwner, executeAfter: executeAfter, approvals: 1, active: true }); hasApproved[account][round][msg.sender] = true; emit RecoveryInitiated(account, round, newOwner, msg.sender, executeAfter); } function supportRecovery(address account) external { if (!isGuardian[account][msg.sender]) revert NotGuardian(); Recovery storage r = activeRecovery[account]; if (!r.active) revert NoActiveRecovery(); uint256 round = recoveryNonce[account]; if (hasApproved[account][round][msg.sender]) revert AlreadyApproved(); hasApproved[account][round][msg.sender] = true; r.approvals += 1; emit RecoverySupported(account, round, msg.sender, r.approvals); } /// @notice Guardian-only. Requires >= threshold approvals AND the 48h timelock. /// Calls account.setRootOwner(newOwner); this module must be installed /// on the account as an executor (module type 2) for that to succeed. function executeRecovery(address account) external { if (!isGuardian[account][msg.sender]) revert NotGuardian(); Recovery memory r = activeRecovery[account]; if (!r.active) revert NoActiveRecovery(); if (r.approvals < thresholdOf[account]) revert BelowThreshold(); if (block.timestamp < r.executeAfter) revert TimelockNotElapsed(r.executeAfter, block.timestamp); delete activeRecovery[account]; IAereModularAccountOwner(account).setRootOwner(r.newOwner); emit RecoveryExecuted(account, recoveryNonce[account], r.newOwner); } /// @notice Cancellable by the current root owner or by the account itself. function cancelRecovery(address account) external { if (msg.sender != account && msg.sender != IAereModularAccountOwner(account).rootOwner()) { revert NotOwner(); } if (!activeRecovery[account].active) revert NoActiveRecovery(); delete activeRecovery[account]; emit RecoveryCancelled(account, recoveryNonce[account], msg.sender); } }