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

190 lines
8.5 KiB
Solidity

// 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);
}
}