diff --git a/.changeset/free-waves-draw.md b/.changeset/free-waves-draw.md new file mode 100644 index 00000000000..dc73e0d75e0 --- /dev/null +++ b/.changeset/free-waves-draw.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579DelayedExecutor`: Add executor module that adds a delay before executing an account operation. diff --git a/.changeset/pink-loops-jump.md b/.changeset/pink-loops-jump.md new file mode 100644 index 00000000000..bb1ca78eb97 --- /dev/null +++ b/.changeset/pink-loops-jump.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579MultisigWeighted`: Add an abstract weighted multisig module that allows different weights to be assigned to signers. diff --git a/.changeset/solid-squids-cough.md b/.changeset/solid-squids-cough.md new file mode 100644 index 00000000000..ff6e9b1d8f0 --- /dev/null +++ b/.changeset/solid-squids-cough.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579Signature`: Add implementation of `ERC7579Validator` that enables ERC-7579 accounts to integrate with address-less cryptographic keys and account signatures through ERC-7913 signature verification. diff --git a/.changeset/weak-chefs-open.md b/.changeset/weak-chefs-open.md new file mode 100644 index 00000000000..9b1132309eb --- /dev/null +++ b/.changeset/weak-chefs-open.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579Executor`: Add an executor module that enables executing calls from accounts where the it's installed. diff --git a/.changeset/wild-masks-worry.md b/.changeset/wild-masks-worry.md new file mode 100644 index 00000000000..b846a5f3820 --- /dev/null +++ b/.changeset/wild-masks-worry.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579Multisig`: Add an abstract multisig module for ERC-7579 accounts using ERC-7913 signer keys. diff --git a/.changeset/yummy-ideas-stay.md b/.changeset/yummy-ideas-stay.md new file mode 100644 index 00000000000..1972dcd0c46 --- /dev/null +++ b/.changeset/yummy-ideas-stay.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7579Validator`: Add abstract validator module for ERC-7579 accounts that provides base implementation for signature validation. diff --git a/contracts/account/README.adoc b/contracts/account/README.adoc index dc3c9a010a7..b6565941601 100644 --- a/contracts/account/README.adoc +++ b/contracts/account/README.adoc @@ -7,6 +7,12 @@ This directory includes contracts to build accounts for ERC-4337. These include: * {Account}: An ERC-4337 smart account implementation that includes the core logic to process user operations. * {AccountERC7579}: An extension of `Account` that implements support for ERC-7579 modules. * {AccountERC7579Hooked}: An extension of `AccountERC7579` with support for a single hook module (type 4). + * {ERC7579Executor}: An executor module that enables executing calls from accounts where the it's installed. + * {ERC7579DelayedExecutor}: An executor module that adds a delay before executing an account operation. + * {ERC7579Validator}: Abstract validator module for ERC-7579 accounts that provides base implementation for signature validation. + * {ERC7579Signature}: Implementation of {ERC7579Validator} using ERC-7913 signature verification for address-less cryptographic keys and account signatures. + * {ERC7579Multisig}: An extension of {ERC7579Validator} that enables validation using ERC-7913 signer keys. + * {ERC7579MultisigWeighted}: An extension of {ERC7579Multisig} that allows different weights to be assigned to signers. * {ERC7821}: Minimal batch executor implementation contracts. Useful to enable easy batch execution for smart contracts. * {ERC4337Utils}: Utility functions for working with ERC-4337 user operations. * {ERC7579Utils}: Utility functions for working with ERC-7579 modules and account modularity. @@ -23,6 +29,24 @@ This directory includes contracts to build accounts for ERC-4337. These include: {{ERC7821}} +== Modules + +=== Executors + +{{ERC7579Executor}} + +{{ERC7579DelayedExecutor}} + +=== Validators + +{{ERC7579Validator}} + +{{ERC7579Signature}} + +{{ERC7579Multisig}} + +{{ERC7579MultisigWeighted}} + == Utilities {{ERC4337Utils}} diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol new file mode 100644 index 00000000000..2d8790bb742 --- /dev/null +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Time} from "../../utils/types/Time.sol"; +import {IERC7579ModuleConfig, MODULE_TYPE_EXECUTOR} from "../../interfaces/draft-IERC7579.sol"; +import {ERC7579Executor} from "./ERC7579Executor.sol"; + +/** + * @dev Extension of {ERC7579Executor} that allows scheduling and executing delayed operations + * with expiration. This module enables time-delayed execution patterns for smart accounts. + * + * ==== Operation Lifecycle + * + * 1. Scheduling: Operations are scheduled via {schedule} with a specified delay period. + * The delay period is set during {onInstall} and can be customized via {setDelay}. Each + * operation enters a `Scheduled` state and must wait for its delay period to elapse. + * + * 2. Security Window: During the delay period, operations remain in `Scheduled` state but + * cannot be executed. Through this period, suspicious operations can be monitored and + * canceled via {cancel} if appropriate. + * + * 3. Execution & Expiration: Once the delay period elapses, operations transition to `Ready` state. + * Operations can be executed via {execute} and have an expiration period after becoming + * executable. If an operation is not executed within the expiration period, it becomes `Expired` + * and can't be executed. Expired operations must be rescheduled with a different salt. + * + * ==== Delay Management + * + * Accounts can set their own delay periods during installation or via {setDelay}. + * The delay period is enforced even between installas and uninstalls to prevent + * immediate downgrades. When setting a new delay period, the new delay takes effect + * after a transition period defined by the current delay or {minSetback}, whichever + * is longer. + * + * ==== Authorization + * + * Authorization for scheduling and canceling operations is controlled through the {_validateSchedule} + * and {_validateCancel} functions. These functions can be overridden to implement custom + * authorization logic, such as requiring specific signers or roles. + * + * TIP: Use {_scheduleAt} to schedule operations at a specific points in time. This is + * useful to pre-schedule operations for non-deployed accounts (e.g. subscriptions). + */ +abstract contract ERC7579DelayedExecutor is ERC7579Executor { + using Time for *; + + struct Schedule { + // 1 slot = 48 + 32 + 32 + 1 + 1 = 114 bits ~ 14 bytes + uint48 scheduledAt; // The time when the operation was scheduled + uint32 executableAfter; // Time after the operation becomes executable + uint32 expiresAfter; // Time after the operation expires + bool executed; + bool canceled; + } + + struct ExecutionConfig { + // 1 slot = 112 + 32 + 1 = 145 bits ~ 18 bytes + Time.Delay delay; + uint32 expiration; // Time after operation is OperationState.Ready to expire + bool installed; + } + + enum OperationState { + Unknown, + Scheduled, + Ready, + Expired, + Executed, + Canceled + } + + /// @dev Emitted when a new operation is scheduled. + event ERC7579ExecutorOperationScheduled( + address indexed account, + bytes32 indexed operationId, + bytes32 salt, + bytes32 mode, + bytes executionCalldata, + uint48 schedule + ); + + /// @dev Emitted when a new operation is canceled. + event ERC7579ExecutorOperationCanceled(address indexed account, bytes32 indexed operationId); + + /// @dev Emitted when the execution delay is updated. + event ERC7579ExecutorDelayUpdated(address indexed account, uint32 newDelay, uint48 effectTime); + + /// @dev Emitted when the expiration delay is updated. + event ERC7579ExecutorExpirationUpdated(address indexed account, uint32 newExpiration); + + /** + * @dev The current state of a operation is not the expected. The `expectedStates` is a bitmap with the + * bits enabled for each OperationState enum position counting from right to left. See {_encodeStateBitmap}. + * + * NOTE: If `expectedState` is `bytes32(0)`, the operation is expected to not be in any state (i.e. not exist). + */ + error ERC7579ExecutorUnexpectedOperationState( + bytes32 operationId, + OperationState currentState, + bytes32 allowedStates + ); + + /// @dev The module is not installed on the account. + error ERC7579ExecutorModuleNotInstalled(); + + mapping(address account => ExecutionConfig) private _config; + mapping(bytes32 operationId => Schedule) private _schedules; + + /// @dev Current state of an operation. + function state( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) public view returns (OperationState) { + return state(hashOperation(account, salt, mode, executionCalldata)); + } + + /// @dev Same as {state}, but for a specific operation id. + function state(bytes32 operationId) public view returns (OperationState) { + if (_schedules[operationId].scheduledAt == 0) return OperationState.Unknown; + if (_schedules[operationId].canceled) return OperationState.Canceled; + if (_schedules[operationId].executed) return OperationState.Executed; + (, uint48 executableAt, uint48 expiresAt) = getSchedule(operationId); + if (block.timestamp < executableAt) return OperationState.Scheduled; + if (block.timestamp >= expiresAt) return OperationState.Expired; + return OperationState.Ready; + } + + /** + * @dev Minimum delay after which {setDelay} takes effect. + * Set as default delay if not provided during {onInstall}. + */ + function minSetback() public view virtual returns (uint32) { + return 5 days; // Up to ~136 years + } + + /// @dev Delay for a specific account. + function getDelay( + address account + ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { + return _config[account].delay.getFull(); + } + + /// @dev Expiration delay for account operations. + function getExpiration(address account) public view virtual returns (uint32 expiration) { + return _config[account].expiration; + } + + /// @dev Schedule for an operation. Returns default values if not set (i.e. `uint48(0)`, `uint48(0)`, `uint48(0)`). + function getSchedule( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt) { + return getSchedule(hashOperation(account, salt, mode, executionCalldata)); + } + + /// @dev Same as {getSchedule} but with the operation id. + function getSchedule( + bytes32 operationId + ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt) { + scheduledAt = _schedules[operationId].scheduledAt; + executableAt = scheduledAt + _schedules[operationId].executableAfter; + return (scheduledAt, executableAt, executableAt + _schedules[operationId].expiresAfter); + } + + /// @dev Returns the operation id. + function hashOperation( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) public view virtual returns (bytes32) { + return keccak256(abi.encode(account, salt, mode, executionCalldata)); + } + + /// @dev Default expiration for account operations. Set if not provided during {onInstall}. + function defaultExpiration() public view virtual returns (uint32) { + return 60 days; + } + + /** + * @dev Sets up the module's initial configuration when installed by an account. + * The account calling this function becomes registered with the module. + * + * The `initData` may be `abi.encode(uint32(initialDelay), uint32(initialExpiration))`. + * The delay will be set to the maximum of this value and the minimum delay if provided. + * Otherwise, the delay will be set to {minSetback} and {defaultExpiration} respectively. + * + * Behaves as a no-op if the module is already installed. + * + * Requirements: + * + * * The account (i.e `msg.sender`) must implement the {IERC7579ModuleConfig} interface. + * * `initData` must be empty or decode correctly to `(uint32, uint32)`. + */ + function onInstall(bytes calldata initData) public virtual { + if (!_config[msg.sender].installed) { + _config[msg.sender].installed = true; + (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 + ? abi.decode(initData, (uint32, uint32)) + : (minSetback(), defaultExpiration()); + // An old delay might be still present + // So we set 0 for the minimum setback relying on any old value as the minimum delay + _setDelay(msg.sender, initialDelay, 0); + _setExpiration(msg.sender, initialExpiration); + } + } + + /** + * @dev Allows an account to update its execution delay (see {getDelay}). + * + * The new delay will take effect after a transition period defined by the current delay + * or {minSetback}, whichever is longer. This prevents immediate security downgrades. + * Can only be called by the account itself. + */ + function setDelay(uint32 newDelay) public virtual { + _setDelay(msg.sender, newDelay, minSetback()); + } + + /// @dev Allows an account to update its execution expiration (see {getExpiration}). + function setExpiration(uint32 newExpiration) public virtual { + _setExpiration(msg.sender, newExpiration); + } + + /** + * @dev Schedules an operation to be executed after the account's delay period (see {getDelay}). + * Operations are uniquely identified by the combination of `salt`, `mode`, and `data`. + * See {_validateSchedule} for authorization checks. + */ + function schedule(address account, bytes32 salt, bytes32 mode, bytes calldata data) public virtual { + require(_config[account].installed, ERC7579ExecutorModuleNotInstalled()); + _validateSchedule(account, salt, mode, data); + (uint32 executableAfter, , ) = getDelay(account); + _scheduleAt(account, salt, mode, data, Time.timestamp(), executableAfter); + } + + /** + * @dev Cancels a previously scheduled operation. Can only be called by the account that + * scheduled the operation. See {_cancel}. + */ + function cancel(address account, bytes32 salt, bytes32 mode, bytes calldata data) public virtual { + _validateCancel(account, salt, mode, data); + _cancel(account, mode, data, salt); // Prioritize errors thrown in _cancel + } + + /** + * @dev Cleans up the {getDelay} and {getExpiration} values by scheduling them to `0` + * and respecting the previous delay and expiration values. + * + * IMPORTANT: This function does not clean up scheduled operations. This means operations + * could potentially be re-executed if the module is reinstalled later. This is a deliberate + * design choice for efficiency, but module implementations may want to override this behavior + * to clear scheduled operations during uninstallation for their specific use cases. + * + * NOTE: Calling this function directly will remove the expiration ({getExpiration}) value and + * will schedule a reset of the delay ({getDelay}) to `0` for the account. Reinstalling the + * module will not immediately reset the delay if the delay reset hasn't taken effect yet. + */ + function onUninstall(bytes calldata) public virtual { + _config[msg.sender].installed = false; + _setDelay(msg.sender, 0, minSetback()); // Avoids immediate downgrades + _setExpiration(msg.sender, 0); + } + + /** + * @dev Returns `data` as the execution calldata. See {ERC7579Executor-_execute}. + * + * NOTE: This function relies on the operation state validation in {_execute} for + * authorization. Extensions of this module should override this function to implement + * additional validation logic if needed. + */ + function _validateExecution( + address /* account */, + bytes32 /* salt */, + bytes32 /* mode */, + bytes calldata data + ) internal virtual override returns (bytes calldata) { + return data; + } + + /** + * @dev Validates whether an operation can be canceled. + * + * Example extension: + * + * ```solidity + * function _validateCancel(address account, bytes32 salt, bytes32 mode, bytes calldata data) internal override { + * // e.g. require(msg.sender == account); + * } + *``` + */ + function _validateCancel( + address account, + bytes32 /* salt */, + bytes32 /* mode */, + bytes calldata /* data */ + ) internal virtual; + + /** + * @dev Validates whether an operation can be scheduled. + * + * Example extension: + * + * ```solidity + * function _validateSchedule(address account, bytes32 salt, bytes32 mode, bytes calldata data) internal override { + * // e.g. require(msg.sender == account); + * } + *``` + */ + function _validateSchedule( + address account, + bytes32 /* salt */, + bytes32 /* mode */, + bytes calldata /* data */ + ) internal virtual; + + /** + * @dev Internal implementation for setting an account's delay. See {getDelay}. + * + * Emits an {ERC7579ExecutorDelayUpdated} event. + */ + function _setDelay(address account, uint32 newDelay, uint32 minimumSetback) internal virtual { + uint48 effect; + (_config[account].delay, effect) = _config[account].delay.withUpdate(newDelay, minimumSetback); + emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); + } + + /** + * @dev Internal implementation for setting an account's expiration. See {getExpiration}. + * + * Emits an {ERC7579ExecutorExpirationUpdated} event. + */ + function _setExpiration(address account, uint32 newExpiration) internal virtual { + // Safe downcast since both arguments are uint32 + _config[account].expiration = newExpiration; + emit ERC7579ExecutorExpirationUpdated(account, newExpiration); + } + + /** + * @dev Internal version of {schedule} that takes an `account` address to schedule + * an operation that starts its security window at `at` and expires after `delay`. + * + * Requirements: + * + * * The operation must be `Unknown`. + * + * Emits an {ERC7579ExecutorOperationScheduled} event. + */ + function _scheduleAt( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata, + uint48 timepoint, + uint32 delay + ) internal virtual returns (bytes32 operationId, Schedule memory schedule_) { + bytes32 id = hashOperation(account, salt, mode, executionCalldata); + _validateStateBitmap(id, _encodeStateBitmap(OperationState.Unknown)); + + _schedules[id].scheduledAt = timepoint; + _schedules[id].executableAfter = delay; + _schedules[id].expiresAfter = getExpiration(account); + + emit ERC7579ExecutorOperationScheduled(account, id, salt, mode, executionCalldata, timepoint + delay); + return (id, schedule_); + } + + /** + * @dev See {ERC7579Executor-_execute}. + * + * Requirements: + * + * * The operation must be `Ready`. + */ + function _execute( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal virtual override returns (bytes[] memory returnData) { + bytes32 id = hashOperation(account, salt, mode, executionCalldata); + _validateStateBitmap(id, _encodeStateBitmap(OperationState.Ready)); + + _schedules[id].executed = true; + + return super._execute(account, salt, mode, executionCalldata); + } + + /** + * @dev Internal version of {cancel} that takes an `account` address as an argument. + * + * Requirements: + * + * * The operation must be `Scheduled` or `Ready`. + * + * Canceled operations can't be rescheduled. Emits an {ERC7579ExecutorOperationCanceled} event. + */ + function _cancel(address account, bytes32 mode, bytes calldata executionCalldata, bytes32 salt) internal virtual { + bytes32 id = hashOperation(account, salt, mode, executionCalldata); + bytes32 allowedStates = _encodeStateBitmap(OperationState.Scheduled) | _encodeStateBitmap(OperationState.Ready); + _validateStateBitmap(id, allowedStates); + + _schedules[id].canceled = true; + + emit ERC7579ExecutorOperationCanceled(account, id); + } + + /** + * @dev Check that the current state of a operation matches the requirements described by the `allowedStates` bitmap. + * This bitmap should be built using {_encodeStateBitmap}. + * + * If requirements are not met, reverts with a {ERC7579ExecutorUnexpectedOperationState} error. + */ + function _validateStateBitmap(bytes32 operationId, bytes32 allowedStates) internal view returns (OperationState) { + OperationState currentState = state(operationId); + require( + _encodeStateBitmap(currentState) & allowedStates != bytes32(0), + ERC7579ExecutorUnexpectedOperationState(operationId, currentState, allowedStates) + ); + return currentState; + } + + /** + * @dev Encodes a `OperationState` into a `bytes32` representation where each bit enabled corresponds to + * the underlying position in the `OperationState` enum. For example: + * + * ``` + * 0x000...10000 + * ^^^^^^------ ... + * ^----- Canceled + * ^---- Executed + * ^--- Ready + * ^-- Scheduled + * ^- Unknown + * ``` + */ + function _encodeStateBitmap(OperationState operationState) internal pure returns (bytes32) { + return bytes32(1 << uint8(operationState)); + } +} diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol new file mode 100644 index 00000000000..7550a88dc50 --- /dev/null +++ b/contracts/account/modules/ERC7579Executor.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IERC7579Module, MODULE_TYPE_EXECUTOR, IERC7579Execution} from "../../interfaces/draft-IERC7579.sol"; + +/** + * @dev Basic implementation for ERC-7579 executor modules that provides execution functionality + * for smart accounts. + * + * The module enables accounts to execute arbitrary operations, leveraging the execution + * capabilities defined in the ERC-7579 standard. Developers can customize whether an operation + * can be executed with custom rules by implementing the {_validateExecution} function in + * derived contracts. + * + * TIP: This is a simplified executor that directly executes operations without delay or expiration + * mechanisms. For a more advanced implementation with time-delayed execution patterns and + * security features, see {ERC7579DelayedExecutor}. + */ +abstract contract ERC7579Executor is IERC7579Module { + /// @dev Emitted when an operation is executed. + event ERC7579ExecutorOperationExecuted( + address indexed account, + bytes32 salt, + bytes32 mode, + bytes executionCalldata + ); + + /// @inheritdoc IERC7579Module + function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { + return moduleTypeId == MODULE_TYPE_EXECUTOR; + } + + /** + * @dev Executes an operation and returns the result data from the executed operation. + * Restricted to the account itself by default. See {_execute} for requirements and + * {_validateExecution} for authorization checks. + */ + function execute( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) public virtual returns (bytes[] memory returnData) { + bytes calldata executionCalldata = _validateExecution(account, salt, mode, data); + returnData = _execute(account, mode, salt, executionCalldata); // Prioritize errors thrown in _execute + return returnData; + } + + /** + * @dev Validates whether the execution can proceed. This function is called before executing + * the operation and returns the execution calldata to be used. + * + * Example extension: + * + * ```solidity + * function _validateExecution(address account, bytes32 salt, bytes32 mode, bytes calldata data) + * internal + * override + * returns (bytes calldata) + * { + * // custom logic + * return data; + * } + *``` + * + * TIP: Pack extra data in the `data` arguments (e.g. a signature) to be used in the + * validation process. Calldata can be sliced to extract it and return only the + * execution calldata. + */ + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal virtual returns (bytes calldata); + + /** + * @dev Internal version of {execute}. Emits {ERC7579ExecutorOperationExecuted} event. + * + * Requirements: + * + * * The `account` must implement the {IERC7579Execution-executeFromExecutor} function. + */ + function _execute( + address account, + bytes32 mode, + bytes32 salt, + bytes calldata executionCalldata + ) internal virtual returns (bytes[] memory returnData) { + emit ERC7579ExecutorOperationExecuted(account, salt, mode, executionCalldata); + return IERC7579Execution(account).executeFromExecutor(mode, executionCalldata); + } +} diff --git a/contracts/account/modules/ERC7579Multisig.sol b/contracts/account/modules/ERC7579Multisig.sol new file mode 100644 index 00000000000..f71a2b9d4b9 --- /dev/null +++ b/contracts/account/modules/ERC7579Multisig.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Mode} from "../../account/utils/draft-ERC7579Utils.sol"; +import {SignatureChecker} from "../../utils/cryptography/SignatureChecker.sol"; +import {EnumerableSet} from "../../utils/structs/EnumerableSet.sol"; +import {ERC7579Validator} from "./ERC7579Validator.sol"; + +/** + * @dev Implementation of an {ERC7579Validator} that uses ERC-7913 signers for multisignature + * validation. + * + * This module provides a base implementation for multisignature validation that can be + * attached to any function through the {_rawERC7579Validation} internal function. The signers + * are represented using the ERC-7913 format, which concatenates a verifier address and + * a key: `verifier || key`. + * + * A smart account with this module installed can require multiple signers to approve + * operations before they are executed, such as requiring 3-of-5 guardians to approve + * a social recovery operation. + */ +abstract contract ERC7579Multisig is ERC7579Validator { + using EnumerableSet for EnumerableSet.BytesSet; + using SignatureChecker for bytes32; + using SignatureChecker for bytes; + + /// @dev Emitted when signers are added. + event ERC7913SignerAdded(address indexed account, bytes signer); + + /// @dev Emitted when signers are removed. + event ERC7913SignerRemoved(address indexed account, bytes signer); + + /// @dev Emitted when the threshold is updated. + event ERC7913ThresholdSet(address indexed account, uint64 threshold); + + /// @dev The `signer` already exists. + error ERC7579MultisigAlreadyExists(bytes signer); + + /// @dev The `signer` does not exist. + error ERC7579MultisigNonexistentSigner(bytes signer); + + /// @dev The `signer` is less than 20 bytes long. + error ERC7579MultisigInvalidSigner(bytes signer); + + /// @dev The `threshold` is zero. + error ERC7579MultisigZeroThreshold(); + + /// @dev The `threshold` is unreachable given the number of `signers`. + error ERC7579MultisigUnreachableThreshold(uint64 signers, uint64 threshold); + + mapping(address account => EnumerableSet.BytesSet) private _signersSetByAccount; + mapping(address account => uint64) private _thresholdByAccount; + + /** + * @dev Sets up the module's initial configuration when installed by an account. + * See {ERC7579DelayedExecutor-onInstall}. Besides the delay setup, the `initdata` can + * include `signers` and `threshold`. + * + * The initData should be encoded as: + * `abi.encode(bytes[] signers, uint64 threshold)` + * + * If no signers or threshold are provided, the multisignature functionality will be + * disabled until they are added later. + * + * NOTE: An account can only call onInstall once. If called directly by the account, + * the signer will be set to the provided data. Future installations will behave as a no-op. + */ + function onInstall(bytes calldata initData) public virtual { + if (initData.length > 32 && getSignerCount(msg.sender) == 0) { + // More than just delay parameter + (bytes[] memory signers_, uint64 threshold_) = abi.decode(initData, (bytes[], uint64)); + _addSigners(msg.sender, signers_); + _setThreshold(msg.sender, threshold_); + } + } + + /** + * @dev Cleans up module's configuration when uninstalled from an account. + * Clears all signers and resets the threshold. + * + * See {ERC7579DelayedExecutor-onUninstall}. + * + * WARNING: This function has unbounded gas costs and may become uncallable if the set grows too large. + * See {EnumerableSet-clear}. + */ + function onUninstall(bytes calldata /* data */) public virtual { + _signersSetByAccount[msg.sender].clear(); + delete _thresholdByAccount[msg.sender]; + } + + /** + * @dev Returns a slice of the set of authorized signers for the specified account. + * + * Using `start = 0` and `end = type(uint64).max` will return the entire set of signers. + * + * WARNING: Depending on the `start` and `end`, this operation can copy a large amount of data to memory, which + * can be expensive. This is designed for view accessors queried without gas fees. Using it in state-changing + * functions may become uncallable if the slice grows too large. + */ + function getSigners(address account, uint64 start, uint64 end) public view virtual returns (bytes[] memory) { + return _signersSetByAccount[account].values(start, end); + } + + /// @dev Returns the number of authorized signers for the specified account. + function getSignerCount(address account) public view virtual returns (uint256) { + return _signersSetByAccount[account].length(); + } + + /// @dev Returns whether the `signer` is an authorized signer for the specified account. + function isSigner(address account, bytes memory signer) public view virtual returns (bool) { + return _signersSetByAccount[account].contains(signer); + } + + /** + * @dev Returns the minimum number of signers required to approve a multisignature operation + * for the specified account. + */ + function threshold(address account) public view virtual returns (uint64) { + return _thresholdByAccount[account]; + } + + /** + * @dev Adds new signers to the authorized set for the calling account. + * Can only be called by the account itself. + * + * Requirements: + * + * * Each of `newSigners` must be at least 20 bytes long. + * * Each of `newSigners` must not be already authorized. + */ + function addSigners(bytes[] memory newSigners) public virtual { + _addSigners(msg.sender, newSigners); + } + + /** + * @dev Removes signers from the authorized set for the calling account. + * Can only be called by the account itself. + * + * Requirements: + * + * * Each of `oldSigners` must be authorized. + * * After removal, the threshold must still be reachable. + */ + function removeSigners(bytes[] memory oldSigners) public virtual { + _removeSigners(msg.sender, oldSigners); + } + + /** + * @dev Sets the threshold for the calling account. + * Can only be called by the account itself. + * + * Requirements: + * + * * The threshold must be reachable with the current number of signers. + */ + function setThreshold(uint64 newThreshold) public virtual { + _setThreshold(msg.sender, newThreshold); + } + + /** + * @dev Returns whether the number of valid signatures meets or exceeds the + * threshold set for the target account. + * + * The signature should be encoded as: + * `abi.encode(bytes[] signingSigners, bytes[] signatures)` + * + * Where `signingSigners` are the authorized signers and signatures are their corresponding + * signatures of the operation `hash`. + */ + function _rawERC7579Validation( + address account, + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (bytes[] memory signingSigners, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); + return + _validateThreshold(account, signingSigners) && + _validateSignatures(account, hash, signingSigners, signatures); + } + + /** + * @dev Adds the `newSigners` to those allowed to sign on behalf of the account. + * + * Requirements: + * + * * Each of `newSigners` must be at least 20 bytes long. Reverts with {ERC7579MultisigInvalidSigner} if not. + * * Each of `newSigners` must not be authorized. Reverts with {ERC7579MultisigAlreadyExists} if it already exists. + */ + function _addSigners(address account, bytes[] memory newSigners) internal virtual { + for (uint256 i = 0; i < newSigners.length; ++i) { + bytes memory signer = newSigners[i]; + require(signer.length >= 20, ERC7579MultisigInvalidSigner(signer)); + require(_signersSetByAccount[account].add(signer), ERC7579MultisigAlreadyExists(signer)); + emit ERC7913SignerAdded(account, signer); + } + } + + /** + * @dev Removes the `oldSigners` from the authorized signers for the account. + * + * Requirements: + * + * * Each of `oldSigners` must be authorized. Reverts with {ERC7579MultisigNonexistentSigner} if not. + * * The threshold must remain reachable after removal. See {_validateReachableThreshold} for details. + */ + function _removeSigners(address account, bytes[] memory oldSigners) internal virtual { + for (uint256 i = 0; i < oldSigners.length; ++i) { + bytes memory signer = oldSigners[i]; + require(_signersSetByAccount[account].remove(signer), ERC7579MultisigNonexistentSigner(signer)); + emit ERC7913SignerRemoved(account, signer); + } + _validateReachableThreshold(account); + } + + /** + * @dev Sets the signatures `threshold` required to approve a multisignature operation. + * + * Requirements: + * + * * The threshold must be greater than 0. Reverts with {ERC7579MultisigZeroThreshold} if not. + * * The threshold must be reachable with the current number of signers. See {_validateReachableThreshold} for details. + */ + function _setThreshold(address account, uint64 newThreshold) internal virtual { + require(newThreshold > 0, ERC7579MultisigZeroThreshold()); + _thresholdByAccount[account] = newThreshold; + _validateReachableThreshold(account); + emit ERC7913ThresholdSet(account, newThreshold); + } + + /** + * @dev Validates the current threshold is reachable with the number of {signers}. + * + * Requirements: + * + * * The number of signers must be >= the threshold. Reverts with {ERC7579MultisigUnreachableThreshold} if not. + */ + function _validateReachableThreshold(address account) internal view virtual { + uint256 totalSigners = getSignerCount(account); + uint64 currentThreshold = threshold(account); + require( + totalSigners >= currentThreshold, + ERC7579MultisigUnreachableThreshold( + uint64(totalSigners), // Safe cast. Economically impossible to overflow. + currentThreshold + ) + ); + } + + /** + * @dev Validates the signatures using the signers and their corresponding signatures. + * Returns whether the signers are authorized and the signatures are valid for the given hash. + * + * The signers must be ordered by their `keccak256` hash to prevent duplications and to optimize + * the verification process. The function will return `false` if any signer is not authorized or + * if the signatures are invalid for the given hash. + * + * Requirements: + * + * * The `signatures` array must be at least the `signers` array's length. + */ + function _validateSignatures( + address account, + bytes32 hash, + bytes[] memory signingSigners, + bytes[] memory signatures + ) internal view virtual returns (bool valid) { + for (uint256 i = 0; i < signingSigners.length; ++i) { + if (!isSigner(account, signingSigners[i])) { + return false; + } + } + return hash.areValidSignaturesNow(signingSigners, signatures); + } + + /** + * @dev Validates that the number of signers meets the {threshold} requirement. + * Assumes the signers were already validated. See {_validateSignatures} for more details. + */ + function _validateThreshold( + address account, + bytes[] memory validatingSigners + ) internal view virtual returns (bool) { + return validatingSigners.length >= threshold(account); + } +} diff --git a/contracts/account/modules/ERC7579MultisigWeighted.sol b/contracts/account/modules/ERC7579MultisigWeighted.sol new file mode 100644 index 00000000000..7c6983a521d --- /dev/null +++ b/contracts/account/modules/ERC7579MultisigWeighted.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {SafeCast} from "../../utils/math/SafeCast.sol"; +import {EnumerableSet} from "../../utils/structs/EnumerableSet.sol"; +import {ERC7579Multisig} from "./ERC7579Multisig.sol"; + +/** + * @dev Extension of {ERC7579Multisig} that supports weighted signatures. + * + * This module extends the multisignature module to allow assigning different weights + * to each signer, enabling more flexible governance schemes. For example, some guardians + * could have higher weight than others, allowing for weighted voting or prioritized authorization. + * + * Example use case: + * + * A smart account with this module installed can schedule social recovery operations + * after obtaining approval from guardians with sufficient total weight (e.g., requiring + * a total weight of 10, with 3 guardians weighted as 5, 3, and 2), and then execute them + * after the time delay has passed. + * + * IMPORTANT: When setting a threshold value, ensure it matches the scale used for signer weights. + * For example, if signers have weights like 1, 2, or 3, then a threshold of 4 would require + * signatures with a total weight of at least 4 (e.g., one with weight 1 and one with weight 3). + */ +abstract contract ERC7579MultisigWeighted is ERC7579Multisig { + using EnumerableSet for EnumerableSet.BytesSet; + using SafeCast for *; + + // Sum of all the extra weights of all signers. Each signer has a base weight of 1. + mapping(address account => uint64 totalExtraWeight) private _totalExtraWeight; + + // Mapping from account => signer => extraWeight (in addition to all authorized signers having weight 1) + mapping(address account => mapping(bytes signer => uint64)) private _extraWeights; + + /** + * @dev Emitted when a signer's weight is changed. + * + * NOTE: Not emitted in {_addSigners} or {_removeSigners}. Indexers must rely on {ERC7913SignerAdded} + * and {ERC7913SignerRemoved} to index a default weight of 1. See {signerWeight}. + */ + event ERC7579MultisigWeightChanged(address indexed account, bytes indexed signer, uint64 weight); + + /// @dev Thrown when a signer's weight is invalid. + error ERC7579MultisigInvalidWeight(bytes signer, uint64 weight); + + /// @dev Thrown when the arrays lengths don't match. + error ERC7579MultisigMismatchedLength(); + + /** + * @dev Sets up the module's initial configuration when installed by an account. + * Besides the standard delay and signer configuration, this can also include + * signer weights. + * + * The initData should be encoded as: + * `abi.encode(bytes[] signers, uint64 threshold, uint64[] weights)` + * + * If weights are not provided but signers are, all signers default to weight 1. + * + * NOTE: An account can only call onInstall once. If called directly by the account, + * the signer will be set to the provided data. Future installations will behave as a no-op. + */ + function onInstall(bytes calldata initData) public virtual override { + bool installed = getSignerCount(msg.sender) > 0; + super.onInstall(initData); + if (initData.length > 96 && !installed) { + (bytes[] memory signers, , uint64[] memory weights) = abi.decode(initData, (bytes[], uint64, uint64[])); + _setSignerWeights(msg.sender, signers, weights); + } + } + + /** + * @dev Cleans up module's configuration when uninstalled from an account. + * Clears all signers, weights, and total weights. + * + * See {ERC7579Multisig-onUninstall}. + */ + function onUninstall(bytes calldata data) public virtual override { + address account = msg.sender; + + bytes[] memory allSigners = getSigners(account, 0, type(uint64).max); + uint256 allSignersLength = allSigners.length; + for (uint256 i = 0; i < allSignersLength; ++i) { + delete _extraWeights[account][allSigners[i]]; + } + delete _totalExtraWeight[account]; + + // Call parent implementation which will clear signers and threshold + super.onUninstall(data); + } + + /// @dev Gets the weight of a signer for a specific account. Returns 0 if the signer is not authorized. + function signerWeight(address account, bytes memory signer) public view virtual returns (uint64) { + unchecked { + // Safe cast, _setSignerWeights guarantees 1+_extraWeights is a uint64 + return uint64(isSigner(account, signer).toUint() * (1 + _extraWeights[account][signer])); + } + } + + /// @dev Gets the total weight of all signers for a specific account. + function totalWeight(address account) public view virtual returns (uint64) { + return (getSignerCount(account) + _totalExtraWeight[account]).toUint64(); + } + + /** + * @dev Sets weights for signers for the calling account. + * Can only be called by the account itself. + */ + function setSignerWeights(bytes[] memory signers, uint64[] memory weights) public virtual { + _setSignerWeights(msg.sender, signers, weights); + } + + /** + * @dev Sets weights for multiple signers at once. Internal version without access control. + * + * Requirements: + * + * * `signers` and `weights` arrays must have the same length. Reverts with {ERC7579MultisigMismatchedLength} on mismatch. + * * Each signer must exist in the set of authorized signers. Reverts with {ERC7579MultisigNonexistentSigner} if not. + * * Each weight must be greater than 0. Reverts with {ERC7579MultisigInvalidWeight} if not. + * * See {_validateReachableThreshold} for the threshold validation. + * + * Emits {ERC7579MultisigWeightChanged} for each signer. + */ + function _setSignerWeights(address account, bytes[] memory signers, uint64[] memory weights) internal virtual { + require(signers.length == weights.length, ERC7579MultisigMismatchedLength()); + + uint256 extraWeightAdded = 0; + uint256 extraWeightRemoved = 0; + for (uint256 i = 0; i < signers.length; ++i) { + bytes memory signer = signers[i]; + require(isSigner(account, signer), ERC7579MultisigNonexistentSigner(signer)); + + uint64 weight = weights[i]; + require(weight > 0, ERC7579MultisigInvalidWeight(signer, weight)); + + unchecked { + uint64 oldExtraWeight = _extraWeights[account][signer]; + uint64 newExtraWeight = weight - 1; + + if (oldExtraWeight != newExtraWeight) { + // Overflow impossible: weight values are bounded by uint64 and economic constraints + extraWeightRemoved += oldExtraWeight; + extraWeightAdded += _extraWeights[account][signer] = newExtraWeight; + emit ERC7579MultisigWeightChanged(account, signer, weight); + } + } + } + unchecked { + // Safe from underflow: `extraWeightRemoved` is bounded by `_totalExtraWeight` by construction + // and weight values are bounded by uint64 and economic constraints + _totalExtraWeight[account] = (uint256(_totalExtraWeight[account]) + extraWeightAdded - extraWeightRemoved) + .toUint64(); + } + _validateReachableThreshold(account); + } + + /** + * @dev Override to add weight tracking. See {ERC7579Multisig-_addSigners}. + * Each new signer has a default weight of 1. + * + * In cases where {totalWeight} is almost `type(uint64).max` (due to a large `_totalExtraWeight`), adding new + * signers could cause the {totalWeight} computation to overflow. Adding a {totalWeight} call after the new + * signers are added ensures no such overflow happens. + */ + function _addSigners(address account, bytes[] memory newSigners) internal virtual override { + super._addSigners(account, newSigners); + + // This will revert if the new signers cause an overflow + _validateReachableThreshold(account); + } + + /** + * @dev Override to handle weight tracking during removal. See {ERC7579Multisig-_removeSigners}. + * + * Just like {_addSigners}, this function does not emit {ERC7579MultisigWeightChanged} events. The + * {ERC7913SignerRemoved} event emitted by {ERC7579Multisig-_removeSigners} is enough to track weights here. + */ + function _removeSigners(address account, bytes[] memory oldSigners) internal virtual override { + // Clean up weights for removed signers + // + // The `extraWeightRemoved` is bounded by `_totalExtraWeight`. The `super._removeSigners` function will revert + // if the signers array contains any duplicates, ensuring each signer's weight is only counted once. Since + // `_totalExtraWeight` is stored as a `uint64`, the final subtraction operation is also safe. + unchecked { + uint64 extraWeightRemoved = 0; + for (uint256 i = 0; i < oldSigners.length; ++i) { + bytes memory signer = oldSigners[i]; + + extraWeightRemoved += _extraWeights[account][signer]; + delete _extraWeights[account][signer]; + } + _totalExtraWeight[account] -= extraWeightRemoved; + } + super._removeSigners(account, oldSigners); + } + + /** + * @dev Override to validate threshold against total weight instead of signer count. + * + * NOTE: This function intentionally does not call `super._validateReachableThreshold` because the base implementation + * assumes each signer has a weight of 1, which is a subset of this weighted implementation. Consider that multiple + * implementations of this function may exist in the contract, so important side effects may be missed + * depending on the linearization order. + */ + function _validateReachableThreshold(address account) internal view virtual override { + uint64 weight = totalWeight(account); + uint64 currentThreshold = threshold(account); + require(weight >= currentThreshold, ERC7579MultisigUnreachableThreshold(weight, currentThreshold)); + } + + /** + * @dev Validates that the total weight of signers meets the {threshold} requirement. + * Overrides the base implementation to use weights instead of count. + * + * NOTE: This function intentionally does not call `super._validateThreshold` because the base implementation + * assumes each signer has a weight of 1, which is incompatible with this weighted implementation. + */ + function _validateThreshold( + address account, + bytes[] memory validatingSigners + ) internal view virtual override returns (bool) { + unchecked { + uint64 weight = 0; + for (uint256 i = 0; i < validatingSigners.length; ++i) { + // Overflow impossible: weight values are bounded by uint64 and economic constraints + weight += signerWeight(account, validatingSigners[i]); + } + return weight >= threshold(account); + } + } +} diff --git a/contracts/account/modules/ERC7579Signature.sol b/contracts/account/modules/ERC7579Signature.sol new file mode 100644 index 00000000000..45e1e60850b --- /dev/null +++ b/contracts/account/modules/ERC7579Signature.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7579Module} from "../../interfaces/draft-IERC7579.sol"; +import {SignatureChecker} from "../../utils/cryptography/SignatureChecker.sol"; +import {ERC7579Validator} from "./ERC7579Validator.sol"; + +/** + * @dev Implementation of {ERC7579Validator} module using ERC-7913 signature verification. + * + * This validator allows ERC-7579 accounts to integrate with address-less cryptographic keys + * and account signatures through the ERC-7913 signature verification system. Each account + * can store its own ERC-7913 formatted signer (a concatenation of a verifier address and a + * key: `verifier || key`). + * + * This enables accounts to use signature schemes without requiring each key to have its own + * Ethereum address.A smart account with this module installed can keep an emergency key as a + * backup. + */ +contract ERC7579Signature is ERC7579Validator { + mapping(address account => bytes signer) private _signers; + + /// @dev Emitted when the signer is set. + event ERC7579SignatureSignerSet(address indexed account, bytes signer); + + /// @dev Thrown when the signer length is less than 20 bytes. + error ERC7579SignatureInvalidSignerLength(); + + /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). + function signer(address account) public view virtual returns (bytes memory) { + return _signers[account]; + } + + /** + * @dev See {IERC7579Module-onInstall}. + * + * NOTE: An account can only call onInstall once. If called directly by the account, + * the signer will be set to the provided data. Future installations will behave as a no-op. + */ + function onInstall(bytes calldata data) public virtual { + if (signer(msg.sender).length == 0) { + setSigner(data); + } + } + + /** + * @dev See {IERC7579Module-onUninstall}. + * + * WARNING: The signer's key will be removed if the account calls this function, potentially + * making the account unusable. As an account operator, make sure to uninstall to a predefined path + * in your account that properly handles side effects of uninstallation. See {AccountERC7579-uninstallModule}. + */ + function onUninstall(bytes calldata) public virtual { + _setSigner(msg.sender, ""); + } + + /// @dev Sets the ERC-7913 signer (i.e. `verifier || key`) for the calling account. + function setSigner(bytes memory signer_) public virtual { + require(signer_.length >= 20, ERC7579SignatureInvalidSignerLength()); + _setSigner(msg.sender, signer_); + } + + /// @dev Internal version of {setSigner} that takes an `account` as argument without validating `signer_`. + function _setSigner(address account, bytes memory signer_) internal virtual { + _signers[account] = signer_; + emit ERC7579SignatureSignerSet(account, signer_); + } + + /** + * @dev See {ERC7579Validator-_rawERC7579Validation}. + * + * Validates a `signature` using ERC-7913 verification. + * + * This base implementation ignores the `sender` parameter and validates using + * the account's stored signer. Derived contracts can override this to implement + * custom validation logic based on the sender. + */ + function _rawERC7579Validation( + address account, + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + return SignatureChecker.isValidSignatureNow(signer(account), hash, signature); + } +} diff --git a/contracts/account/modules/ERC7579Validator.sol b/contracts/account/modules/ERC7579Validator.sol new file mode 100644 index 00000000000..c456ef84897 --- /dev/null +++ b/contracts/account/modules/ERC7579Validator.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7579Module, IERC7579Validator, MODULE_TYPE_VALIDATOR} from "../../interfaces/draft-IERC7579.sol"; +import {PackedUserOperation} from "../../interfaces/draft-IERC4337.sol"; +import {ERC4337Utils} from "../../account/utils/draft-ERC4337Utils.sol"; +import {IERC1271} from "../../interfaces/IERC1271.sol"; + +/** + * @dev Abstract validator module for ERC-7579 accounts. + * + * This contract provides the base implementation for signature validation in ERC-7579 accounts. + * Developers must implement the onInstall, onUninstall, and {_rawERC7579Validation} + * functions in derived contracts to define the specific signature validation logic. + * + * Example usage: + * + * ```solidity + * contract MyValidatorModule is ERC7579Validator { + * function onInstall(bytes calldata data) public { + * // Install logic here + * } + * + * function onUninstall(bytes calldata data) public { + * // Uninstall logic here + * } + * + * function _rawERC7579Validation( + * address account, + * bytes32 hash, + * bytes calldata signature + * ) internal view override returns (bool) { + * // Signature validation logic here + * } + * } + * ``` + * + * Developers can restrict other operations by using the internal {_rawERC7579Validation}. + * Example usage: + * + * ```solidity + * function execute( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt, + * bytes calldata signature + * ) public virtual { + * require(_rawERC7579Validation(account, hash, signature)); + * // ... rest of execute logic + * } + * ``` + */ +abstract contract ERC7579Validator is IERC7579Module, IERC7579Validator { + /// @inheritdoc IERC7579Module + function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { + return moduleTypeId == MODULE_TYPE_VALIDATOR; + } + + /// @inheritdoc IERC7579Validator + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) public view virtual returns (uint256) { + return + _rawERC7579Validation(msg.sender, userOpHash, userOp.signature) + ? ERC4337Utils.SIG_VALIDATION_SUCCESS + : ERC4337Utils.SIG_VALIDATION_FAILED; + } + + /** + * @dev See {IERC7579Validator-isValidSignatureWithSender}. + * + * Ignores the `sender` parameter and validates using {_rawERC7579Validation}. + * Consider overriding this function to implement custom validation logic + * based on the original sender. + */ + function isValidSignatureWithSender( + address /* sender */, + bytes32 hash, + bytes calldata signature + ) public view virtual returns (bytes4) { + return + _rawERC7579Validation(msg.sender, hash, signature) + ? IERC1271.isValidSignature.selector + : bytes4(0xffffffff); + } + + /** + * @dev Validation algorithm. + * + * WARNING: Validation is a critical security function. Implementations must carefully + * handle cryptographic verification to prevent unauthorized access. + */ + function _rawERC7579Validation( + address account, + bytes32 hash, + bytes calldata signature + ) internal view virtual returns (bool); +} diff --git a/contracts/mocks/account/modules/ERC7579ExecutorMocks.sol b/contracts/mocks/account/modules/ERC7579ExecutorMocks.sol new file mode 100644 index 00000000000..85f7dd13894 --- /dev/null +++ b/contracts/mocks/account/modules/ERC7579ExecutorMocks.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7579Executor} from "../../../account/modules/ERC7579Executor.sol"; +import {ERC7579DelayedExecutor} from "../../../account/modules/ERC7579DelayedExecutor.sol"; + +abstract contract ERC7579ExecutorMock is ERC7579Executor { + function onInstall(bytes calldata data) external {} + + function onUninstall(bytes calldata data) external {} + + function _validateExecution( + address, + bytes32, + bytes32, + bytes calldata data + ) internal pure override returns (bytes calldata) { + return data; + } +} + +abstract contract ERC7579DelayedExecutorMock is ERC7579DelayedExecutor { + function _validateSchedule(address account, bytes32, bytes32, bytes calldata) internal view override { + require(msg.sender == account); + } + + function _validateCancel(address account, bytes32, bytes32, bytes calldata) internal view override { + require(msg.sender == account); + } +} diff --git a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol new file mode 100644 index 00000000000..0796d69b839 --- /dev/null +++ b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {EIP712} from "../../../utils/cryptography/EIP712.sol"; +import {ERC7579Executor} from "../../../account/modules/ERC7579Executor.sol"; +import {ERC7579Validator} from "../../../account/modules/ERC7579Validator.sol"; +import {ERC7579Multisig} from "../../../account/modules/ERC7579Multisig.sol"; +import {ERC7579MultisigWeighted} from "../../../account/modules/ERC7579MultisigWeighted.sol"; +import {MODULE_TYPE_EXECUTOR} from "../../../interfaces/draft-IERC7579.sol"; + +abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC7579Multisig { + bytes32 private constant EXECUTE_OPERATION = + keccak256("ExecuteOperation(address account,bytes32 mode,bytes executionCalldata,bytes32 salt)"); + + function isModuleType(uint256 moduleTypeId) public pure override(ERC7579Executor, ERC7579Validator) returns (bool) { + return ERC7579Executor.isModuleType(moduleTypeId) || ERC7579Executor.isModuleType(moduleTypeId); + } + + // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal view override returns (bytes calldata) { + uint16 executionCalldataLength = uint16(bytes2(data[0:2])); // First 2 bytes are the length + bytes calldata executionCalldata = data[2:2 + executionCalldataLength]; // Next bytes are the calldata + bytes32 typeHash = _getExecuteTypeHash(account, salt, mode, executionCalldata); + require(_rawERC7579Validation(account, typeHash, data[2 + executionCalldataLength:])); // Remaining bytes are the signature + return executionCalldata; + } + + function _getExecuteTypeHash( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, salt, mode, executionCalldata))); + } +} + +abstract contract ERC7579MultisigWeightedExecutorMock is EIP712, ERC7579Executor, ERC7579MultisigWeighted { + bytes32 private constant EXECUTE_OPERATION = + keccak256("ExecuteOperation(address account,bytes32 mode,bytes executionCalldata,bytes32 salt)"); + + function isModuleType(uint256 moduleTypeId) public pure override(ERC7579Executor, ERC7579Validator) returns (bool) { + return ERC7579Executor.isModuleType(moduleTypeId) || ERC7579Executor.isModuleType(moduleTypeId); + } + + // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal view override returns (bytes calldata) { + uint16 executionCalldataLength = uint16(bytes2(data[0:2])); // First 2 bytes are the length + bytes calldata executionCalldata = data[2:2 + executionCalldataLength]; // Next bytes are the calldata + bytes32 typeHash = _getExecuteTypeHash(account, salt, mode, executionCalldata); + require(_rawERC7579Validation(account, typeHash, data[2 + executionCalldataLength:])); // Remaining bytes are the signature + return executionCalldata; + } + + function _getExecuteTypeHash( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, salt, mode, executionCalldata))); + } +} diff --git a/contracts/mocks/docs/account/modules/MyERC7579DelayedSocialRecovery.sol b/contracts/mocks/docs/account/modules/MyERC7579DelayedSocialRecovery.sol new file mode 100644 index 00000000000..d234260a762 --- /dev/null +++ b/contracts/mocks/docs/account/modules/MyERC7579DelayedSocialRecovery.sol @@ -0,0 +1,57 @@ +// contracts/MyERC7579DelayedSocialRecovery.sol +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {EIP712} from "../../../../utils/cryptography/EIP712.sol"; +import {ERC7579Executor} from "../../../../account/modules/ERC7579Executor.sol"; +import {ERC7579Validator} from "../../../../account/modules/ERC7579Validator.sol"; +import {Calldata} from "../../../../utils/Calldata.sol"; +import {ERC7579DelayedExecutor} from "../../../../account/modules/ERC7579DelayedExecutor.sol"; +import {ERC7579Multisig} from "../../../../account/modules/ERC7579Multisig.sol"; + +abstract contract MyERC7579DelayedSocialRecovery is EIP712, ERC7579DelayedExecutor, ERC7579Multisig { + bytes32 private constant RECOVER_TYPEHASH = + keccak256("Recover(address account,bytes32 salt,bytes32 mode,bytes executionCalldata)"); + + function isModuleType(uint256 moduleTypeId) public pure override(ERC7579Executor, ERC7579Validator) returns (bool) { + return ERC7579Executor.isModuleType(moduleTypeId) || ERC7579Executor.isModuleType(moduleTypeId); + } + + // Data encoding: [uint16(executorArgsLength), executorArgs, uint16(multisigArgsLength), multisigArgs] + function onInstall(bytes calldata data) public override(ERC7579DelayedExecutor, ERC7579Multisig) { + uint16 executorArgsLength = uint16(bytes2(data[0:2])); // First 2 bytes are the length + bytes calldata executorArgs = data[2:2 + executorArgsLength]; // Next bytes are the args + uint16 multisigArgsLength = uint16(bytes2(data[2 + executorArgsLength:4 + executorArgsLength])); // Next 2 bytes are the length + bytes calldata multisigArgs = data[4 + executorArgsLength:4 + executorArgsLength + multisigArgsLength]; // Next bytes are the args + + ERC7579DelayedExecutor.onInstall(executorArgs); + ERC7579Multisig.onInstall(multisigArgs); + } + + function onUninstall(bytes calldata) public override(ERC7579DelayedExecutor, ERC7579Multisig) { + ERC7579DelayedExecutor.onUninstall(Calldata.emptyBytes()); + ERC7579Multisig.onUninstall(Calldata.emptyBytes()); + } + + // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] + function _validateSchedule( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal view override { + uint16 executionCalldataLength = uint16(bytes2(data[0:2])); // First 2 bytes are the length + bytes calldata executionCalldata = data[2:2 + executionCalldataLength]; // Next bytes are the calldata + bytes calldata signature = data[2 + executionCalldataLength:]; // Remaining bytes are the signature + require(_rawERC7579Validation(account, _getExecuteTypeHash(account, salt, mode, executionCalldata), signature)); + } + + function _getExecuteTypeHash( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(RECOVER_TYPEHASH, account, salt, mode, executionCalldata))); + } +} diff --git a/contracts/mocks/docs/account/modules/MyERC7579Modules.sol b/contracts/mocks/docs/account/modules/MyERC7579Modules.sol new file mode 100644 index 00000000000..5724527e096 --- /dev/null +++ b/contracts/mocks/docs/account/modules/MyERC7579Modules.sol @@ -0,0 +1,20 @@ +// contracts/MyERC7579Modules.sol +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {EIP712} from "../../../../utils/cryptography/EIP712.sol"; +import {IERC7579Module, IERC7579Hook} from "../../../../interfaces/draft-IERC7579.sol"; +import {ERC7579Executor} from "../../../../account/modules/ERC7579Executor.sol"; +import {ERC7579Validator} from "../../../../account/modules/ERC7579Validator.sol"; + +// Basic validator module +abstract contract MyERC7579RecoveryValidator is ERC7579Validator {} + +// Basic executor module +abstract contract MyERC7579RecoveryExecutor is ERC7579Executor {} + +// Basic fallback handler +abstract contract MyERC7579RecoveryFallback is IERC7579Module {} + +// Basic hook +abstract contract MyERC7579RecoveryHook is IERC7579Hook {} diff --git a/contracts/mocks/docs/account/modules/MyERC7579SocialRecovery.sol b/contracts/mocks/docs/account/modules/MyERC7579SocialRecovery.sol new file mode 100644 index 00000000000..da335a19bfe --- /dev/null +++ b/contracts/mocks/docs/account/modules/MyERC7579SocialRecovery.sol @@ -0,0 +1,44 @@ +// contracts/MyERC7579SocialRecovery.sol +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Nonces} from "../../../../utils/Nonces.sol"; +import {EIP712} from "../../../../utils/cryptography/EIP712.sol"; +import {ERC7579Executor} from "../../../../account/modules/ERC7579Executor.sol"; +import {ERC7579Validator} from "../../../../account/modules/ERC7579Validator.sol"; +import {ERC7579Multisig} from "../../../../account/modules/ERC7579Multisig.sol"; + +abstract contract MyERC7579SocialRecovery is EIP712, ERC7579Executor, ERC7579Multisig, Nonces { + bytes32 private constant RECOVER_TYPEHASH = + keccak256("Recover(address account,bytes32 salt,uint256 nonce,bytes32 mode,bytes executionCalldata)"); + + function isModuleType(uint256 moduleTypeId) public pure override(ERC7579Executor, ERC7579Validator) returns (bool) { + return ERC7579Executor.isModuleType(moduleTypeId) || ERC7579Executor.isModuleType(moduleTypeId); + } + + // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal override returns (bytes calldata) { + uint16 executionCalldataLength = uint16(bytes2(data[0:2])); // First 2 bytes are the length + bytes calldata executionCalldata = data[2:2 + executionCalldataLength]; // Next bytes are the calldata + bytes calldata signature = data[2 + executionCalldataLength:]; // Remaining bytes are the signature + require(_rawERC7579Validation(account, _getExecuteTypeHash(account, salt, mode, executionCalldata), signature)); + return executionCalldata; + } + + function _getExecuteTypeHash( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal returns (bytes32) { + return + _hashTypedDataV4( + keccak256(abi.encode(RECOVER_TYPEHASH, account, salt, _useNonce(account), mode, executionCalldata)) + ); + } +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 2b347c890c7..04e90da59e6 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -11,6 +11,7 @@ ** xref:accounts.adoc[Accounts] *** xref:eoa-delegation.adoc[EOA Delegation] *** xref:multisig.adoc[Multisig] +*** xref:account-modules.adoc[Modules] * xref:tokens.adoc[Tokens] ** xref:erc20.adoc[ERC-20] diff --git a/docs/modules/ROOT/pages/account-modules.adoc b/docs/modules/ROOT/pages/account-modules.adoc new file mode 100644 index 00000000000..f35bda56084 --- /dev/null +++ b/docs/modules/ROOT/pages/account-modules.adoc @@ -0,0 +1,142 @@ += Account Modules + +Smart accounts built with https://eips.ethereum.org/EIPS/eip-7579[ERC-7579] provide a standardized way to extend account functionality through modules (i.e. smart contract instances). This architecture allows accounts to support various features that are compatible with a wide variety of account implementations. See https://erc7579.com/modules[compatible modules]. + +== ERC-7579 + +ERC-7579 defines a standardized interface for modular smart accounts. This standard enables accounts to install, uninstall, and interact with modules that extend their capabilities in a composable manner with different account implementations. + +=== Accounts + +OpenZeppelin offers an implementation of an xref:api:account.adoc#AccountERC7579[`AccountERC7579`] contract that allows installing modules compliant with this standard. There's also an xref:api:account.adoc#AccountERC7579Hooked[`AccountERC7579Hooked`] variant that supports installation of hooks. Like xref:accounts.adoc#handling_initialization[most accounts], an instance should define an initializer function where the first module that controls the account will be set: + +[source,solidity] +---- +// contracts/MyAccountERC7579.sol +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {AccountERC7579} from "@openzeppelin/contracts/account/extensions/draft-AccountERC7579.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {MODULE_TYPE_VALIDATOR} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; + +contract MyAccountERC7579 is Initializable, AccountERC7579 { + function initializeAccount(address validator, bytes calldata validatorData) public initializer { + // Install a validator module to handle signature verification + _installModule(MODULE_TYPE_VALIDATOR, validator, validatorData); + } +} +---- + +NOTE: For simplicity, the xref:api:account.adoc#AccountERC7579Hooked[`AccountERC7579Hooked`] only supports a single hook. A common workaround is to install a https://github.com/rhinestonewtf/core-modules/blob/7afffccb44d73dbaca2481e7b92bce0621ea6449/src/HookMultiPlexer/HookMultiPlexer.sol[single hook with a multiplexer pattern] to extend the functionality to multiple hooks. + +=== Modules + +Functionality is added to accounts through encapsulated functionality deployed as smart contracts called _modules_. The standard defines four primary module types: + +* *Validator modules (type 1)*: Handle signature verification and user operation validation +* *Executor modules (type 2)*: Execute operations on behalf of the account +* *Fallback modules (type 3)*: Handle fallback calls for specific function selectors +* *Hook modules (type 4)*: Execute logic before and after operations + +Modules can implement multiple types simultaneously, which means you could combine an executor module with hooks to enforce behaviors on an account, such as maintaining ERC-20 approvals or preventing the removal of certain permissions. + +See https://erc7579.com/modules[popular module implementations]. + +==== Building Custom Modules + +The library provides _standard composable modules_ as building blocks with an internal API for developers. By combining these components, you can create a rich set of variants without including unnecessary features. + +A good starting point is the xref:api:account.adoc#ERC7579Executor[`ERC7579Executor`] or xref:api:account.adoc#ERC7579Validator[`ERC7579Validator`], which include an opinionated base layer easily combined with other abstract modules. Hooks and fallback handlers are more straightforward to implement directly from interfaces: + +[source,solidity] +---- +include::api:example$account/modules/MyERC7579Modules.sol[] +---- + +TIP: Explore these abstract ERC-7579 modules in the xref:api:account.adoc#modules[API Reference]. + +==== Execution Modes + +ERC-7579 supports various execution modes, which are encoded as a `bytes32` value. The https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/account/utils/draft-ERC7579Utils.sol[`ERC7579Utils`] library provides utility functions to work with these modes: + +[source,solidity] +---- +// Parts of an execution mode +type Mode is bytes32; +type CallType is bytes1; +type ExecType is bytes1; +type ModeSelector is bytes4; +type ModePayload is bytes22; +---- + +===== Call Types + +Call types determine the kind of execution: + +[%header,cols="1,1,3"] +|=== +|Type |Value |Description +|`CALLTYPE_SINGLE` |`0x00` |A single `call` execution +|`CALLTYPE_BATCH` |`0x01` |A batch of `call` executions +|`CALLTYPE_DELEGATECALL` |`0xFF` |A `delegatecall` execution +|=== + +===== Execution Types + +Execution types determine how failures are handled: + +[%header,cols="1,1,3"] +|=== +|Type |Value |Description +|`EXECTYPE_DEFAULT` |`0x00` |Reverts on failure +|`EXECTYPE_TRY` |`0x01` |Does not revert on failure, emits an event instead +|=== + +==== Execution Data Format + +The execution data format varies depending on the call type: + +* For single calls: `abi.encodePacked(target, value, callData)` +* For batched calls: `abi.encode(Execution[])` where `Execution` is a struct containing `target`, `value`, and `callData` +* For delegate calls: `abi.encodePacked(target, callData)` + +== Examples + +=== Social Recovery + +Social recovery allows an account to be recovered when access is lost by relying on trusted parties ("guardians") who verify the user's identity and help restore access. + +Social recovery is not a single solution but a design space with multiple configuration options: + +* Delay configuration +* Expiration settings +* Different guardian types +* Cancellation windows +* Confirmation requirements + +To support _different guardian types_, we can leverage ERC-7913 as discussed in the xref:multisig.adoc#beyond_standard_signature_verification[multisig] section. For ERC-7579 modules, this is implemented through the xref:api:account.adoc#ERC7579Multisig[`ERC7579Multisig`] validator. + +Combined with an xref:api:account.adoc#ERC7579Executor[`ERC7579Executor`], it provides a basic foundation that can be extended with more sophisticated features: + +[source,solidity] +---- +include::api:example$account/modules/MyERC7579SocialRecovery.sol[] +---- + +For enhanced security, you can extend this foundation with scheduling, delays, and cancellations using xref:api:account.adoc#ERC7579DelayedExecutor[`ERC7579DelayedExecutor`]. This allows guardians to schedule recovery operations with a time delay, providing a security window to detect and cancel suspicious recovery attempts before they execute: + +[source,solidity] +---- +include::api:example$account/modules/MyERC7579DelayedSocialRecovery.sol[] +---- + +NOTE: The delayed executor's signature validation doesn't require a nonce since operations are uniquely identified by their xref:api:account.adoc#ERC7579DelayedExecutor-hashOperation-address-bytes32-bytes32-bytes-[operation id] and cannot be scheduled twice. + +These implementations demonstrate how to build progressively more secure social recovery mechanisms, from basic multi-signature recovery to time-delayed recovery with cancellation capabilities. + +For additional functionality, developers can use: + +* xref:api:account.adoc#ERC7579MultisigWeighted[`ERC7579MultisigWeighted`] to assign different weights to signers + diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js new file mode 100644 index 00000000000..20125ddc83a --- /dev/null +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -0,0 +1,300 @@ +const { ethers, predeploy } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture, time } = require('@nomicfoundation/hardhat-network-helpers'); + +const { impersonate } = require('../../helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { + MODULE_TYPE_EXECUTOR, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, + encodeMode, + encodeSingle, +} = require('../../helpers/erc7579'); +const { ERC7579OperationState } = require('../../helpers/enums'); + +const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 validator module + const mock = await ethers.deployContract('$ERC7579DelayedExecutorMock'); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + + // Prepare module installation data + const delay = time.duration.days(10); + const expiration = time.duration.years(1); + const installData = ethers.AbiCoder.defaultAbiCoder().encode(['uint32', 'uint32'], [delay, expiration]); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + const mockAccountFromEntrypoint = await impersonate(predeploy.entrypoint.v08.target).then(asEntrypoint => + mockAccount.connect(asEntrypoint), + ); + + const moduleType = MODULE_TYPE_EXECUTOR; + + await mockAccount.deploy(); + + const args = [42, '0x1234']; + const data = target.interface.encodeFunctionData('mockFunctionWithArgs', args); + const calldata = encodeSingle(target, 0, data); + const mode = encodeMode({ callType: CALL_TYPE_CALL, execType: EXEC_TYPE_DEFAULT }); + + return { + moduleType, + mock, + mockAccount, + mockFromAccount, + mockAccountFromEntrypoint, + target, + installData, + args, + data, + calldata, + mode, + delay, + expiration, + other, + }; +} + +describe('ERC7579DelayedExecutor', function () { + const salt = ethers.ZeroHash; + + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeERC7579Module(); + + it('returns the correct state (complete execution)', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Unknown, + ); + await this.mockFromAccount.schedule(this.mockAccount.address, salt, this.mode, this.calldata); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Scheduled, + ); + await time.increase(this.delay); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Ready, + ); + await this.mock.execute(this.mockAccount.address, salt, this.mode, this.calldata); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Executed, + ); + }); + + it('returns the correct state (expiration)', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Unknown, + ); + await this.mockFromAccount.schedule(this.mockAccount.address, salt, this.mode, this.calldata); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Scheduled, + ); + await time.increase(this.delay); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Ready, + ); + await time.increase(this.expiration); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Expired, + ); + }); + + it('returns the correct state (cancellation)', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Unknown, + ); + await this.mockFromAccount.schedule(this.mockAccount.address, salt, this.mode, this.calldata); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Scheduled, + ); + await time.increase(this.delay); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Ready, + ); + await this.mockFromAccount.cancel(this.mockAccount.address, salt, this.mode, this.calldata); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + ERC7579OperationState.Canceled, + ); + }); + + it('sets an initial delay and expiration on installation', async function () { + const tx = await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + const now = await time.latest(); + await expect(tx) + .to.emit(this.mock, 'ERC7579ExecutorDelayUpdated') + .withArgs(this.mockAccount.address, this.delay, now) + .to.emit(this.mock, 'ERC7579ExecutorExpirationUpdated') + .withArgs(this.mockAccount.address, this.expiration); + + // onInstall is allowed again but a noop + await this.mockFromAccount.onInstall( + ethers.AbiCoder.defaultAbiCoder().encode(['uint32', 'uint32'], [time.duration.days(3), time.duration.hours(12)]), + ); + await expect(this.mock.getDelay(this.mockAccount.address)).to.eventually.deep.equal([this.delay, 0, 0]); + }); + + it('sets default delay and expiration on installation', async function () { + const tx = await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, '0x'); + const now = await time.latest(); + await expect(tx) + .to.emit(this.mock, 'ERC7579ExecutorDelayUpdated') + .withArgs(this.mockAccount.address, time.duration.days(5), now) + .to.emit(this.mock, 'ERC7579ExecutorExpirationUpdated') + .withArgs(this.mockAccount.address, time.duration.days(60)); + }); + + it('schedule delay unset and unsets expiration on uninstallation', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + const tx = await this.mockAccountFromEntrypoint.uninstallModule(this.moduleType, this.mock.target, '0x'); + const now = await time.latest(); + await expect(tx) + .to.emit(this.mock, 'ERC7579ExecutorDelayUpdated') + .withArgs(this.mockAccount.address, 0, now + this.delay) // Old delay + .to.emit(this.mock, 'ERC7579ExecutorExpirationUpdated') + .withArgs(this.mockAccount.address, 0); + }); + + it('schedules a delay update', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + + const newDelay = time.duration.days(5); + const tx = await this.mockFromAccount.setDelay(newDelay); + const now = await time.latest(); + const effect = now + this.delay - newDelay; + + // Delay is scheduled, will take effect later + await expect(tx) + .to.emit(this.mock, 'ERC7579ExecutorDelayUpdated') + .withArgs(this.mockAccount.address, newDelay, effect); + await expect(this.mock.getDelay(this.mockAccount.target)).to.eventually.deep.equal([this.delay, newDelay, effect]); + + // Later, it takes effect + await time.increaseTo(effect); + await expect(this.mock.getDelay(this.mockAccount.target)).to.eventually.deep.equal([newDelay, 0, 0]); + }); + + it('updates the expiration', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + + const newExpiration = time.duration.weeks(10); + await expect(this.mockFromAccount.setExpiration(newExpiration)) + .to.emit(this.mock, 'ERC7579ExecutorExpirationUpdated') + .withArgs(this.mockAccount.address, newExpiration); + await expect(this.mock.getExpiration(this.mockAccount.target)).to.eventually.equal(newExpiration); + }); + + describe('scheduling', function () { + beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + }); + + it('schedules an operation if called by the account', async function () { + const id = this.mock.hashOperation(this.mockAccount.address, salt, this.mode, this.calldata); + const tx = await this.mockFromAccount.schedule(this.mockAccount.address, salt, this.mode, this.calldata); + const now = await time.latest(); + await expect(tx) + .to.emit(this.mock, 'ERC7579ExecutorOperationScheduled') + .withArgs(this.mockAccount.address, id, salt, this.mode, this.calldata, now + this.delay); + await expect( + this.mockFromAccount.schedule(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't schedule twice + await expect( + this.mock.getSchedule(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.eventually.deep.equal([now, now + this.delay, now + this.delay + this.expiration]); + }); + + it('reverts with ERC7579ExecutorModuleNotInstalled if the module is not installed', async function () { + await expect( + this.mock.schedule(this.other.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorModuleNotInstalled'); + }); + }); + + describe('execution', function () { + beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + const now = await time.latest(); + const [delay] = await this.mock.getDelay(this.mockAccount.address); + await this.mock.$_scheduleAt(this.mockAccount.address, salt, this.mode, this.calldata, now, delay); + }); + + it('reverts with ERC7579ExecutorUnexpectedOperationState before delay passes with any caller', async function () { + await expect( + this.mock.execute(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); + }); + + it('reverts with ERC7579ExecutorUnexpectedOperationState before delay passes with the account as caller', async function () { + await expect( + this.mockFromAccount.execute(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Allowed, not ready + }); + + it('executes if called by the account when delay passes but has not expired with any caller', async function () { + await time.increase(this.delay); + await expect(this.mock.execute(this.mockAccount.address, salt, this.mode, this.calldata)) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(...this.args); + await expect( + this.mock.execute(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't execute twice + }); + + it('executes if called by the account when delay passes but has not expired with the account as caller', async function () { + await time.increase(this.delay); + await expect(this.mockFromAccount.execute(this.mockAccount.address, salt, this.mode, this.calldata)) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(...this.args); + await expect( + this.mockFromAccount.execute(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't execute twice + }); + + it('reverts with ERC7579ExecutorUnexpectedOperationState if the operation was expired with any caller', async function () { + await time.increase(this.delay + this.expiration); + await expect( + this.mock.execute(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); + }); + + it('reverts if the operation was expired with the account as caller', async function () { + await time.increase(this.delay + this.expiration); + await expect( + this.mockFromAccount.execute(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Allowed, expired + }); + }); + + describe('cancelling', function () { + beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + const now = await time.latest(); + const [delay] = await this.mock.getDelay(this.mockAccount.address); + await this.mock.$_scheduleAt(this.mockAccount.address, salt, this.mode, this.calldata, now, delay); + }); + + it('cancels an operation if called by the account', async function () { + const id = this.mock.hashOperation(this.mockAccount.address, salt, this.mode, this.calldata); + await expect(this.mockFromAccount.cancel(this.mockAccount.address, salt, this.mode, this.calldata)) + .to.emit(this.mock, 'ERC7579ExecutorOperationCanceled') + .withArgs(this.mockAccount.address, id); + await expect( + this.mockFromAccount.cancel(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't cancel twice + }); + }); +}); diff --git a/test/account/modules/ERC7579Executor.test.js b/test/account/modules/ERC7579Executor.test.js new file mode 100644 index 00000000000..a3ed5eaf66c --- /dev/null +++ b/test/account/modules/ERC7579Executor.test.js @@ -0,0 +1,74 @@ +const { ethers, predeploy } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { impersonate } = require('../../helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { + MODULE_TYPE_EXECUTOR, + encodeSingle, + encodeMode, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, +} = require('../../helpers/erc7579'); + +const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); + +async function fixture() { + // Deploy ERC-7579 validator module + const mock = await ethers.deployContract('$ERC7579ExecutorMock'); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + + // Prepare module installation data + const installData = '0x'; + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + + const moduleType = MODULE_TYPE_EXECUTOR; + + await mockAccount.deploy(); + await impersonate(predeploy.entrypoint.v08.target).then(asEntrypoint => + mockAccount.connect(asEntrypoint).installModule(moduleType, mock.target, installData), + ); + + const args = [42, '0x1234']; + const data = target.interface.encodeFunctionData('mockFunctionWithArgs', args); + const calldata = encodeSingle(target, 0, data); + const mode = encodeMode({ callType: CALL_TYPE_CALL, execType: EXEC_TYPE_DEFAULT }); + + return { + moduleType, + mock, + mockAccount, + mockFromAccount, + target, + installData, + args, + data, + calldata, + mode, + }; +} + +describe('ERC7579Executor', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('execute', function () { + it('succeeds', async function () { + await expect(this.mockFromAccount.$_execute(this.mockAccount.address, ethers.ZeroHash, this.mode, this.calldata)) + .to.emit(this.mock, 'ERC7579ExecutorOperationExecuted') + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(...this.args); + }); + }); + + shouldBehaveLikeERC7579Module(); +}); diff --git a/test/account/modules/ERC7579Module.behavior.js b/test/account/modules/ERC7579Module.behavior.js new file mode 100644 index 00000000000..f309662d502 --- /dev/null +++ b/test/account/modules/ERC7579Module.behavior.js @@ -0,0 +1,70 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('../../helpers/erc4337'); + +function shouldBehaveLikeERC7579Module() { + describe('behaves like ERC7579Module', function () { + it('identifies its module type correctly', async function () { + await expect(this.mock.isModuleType(this.moduleType)).to.eventually.be.true; + await expect(this.mock.isModuleType(999)).to.eventually.be.false; // Using random unassigned module type + }); + + it('handles installation, uninstallation, and re-installation', async function () { + await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted; + await expect(this.mockFromAccount.onUninstall(this.uninstallData || '0x')).to.not.be.reverted; + await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted; + }); + }); +} + +function shouldBehaveLikeERC7579Validator() { + describe('behaves like ERC7579Validator', function () { + const MAGIC_VALUE = '0x1626ba7e'; + const INVALID_VALUE = '0xffffffff'; + + beforeEach(async function () { + await this.mockFromAccount.onInstall(this.installData); + }); + + describe('validateUserOp', function () { + it('returns SIG_VALIDATION_SUCCESS when signature is valid', async function () { + const userOp = await this.mockAccount.createUserOp(this.userOp).then(op => this.signUserOp(op)); + await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( + SIG_VALIDATION_SUCCESS, + ); + }); + + it('returns SIG_VALIDATION_FAILURE when signature is invalid', async function () { + const userOp = await this.mockAccount.createUserOp(this.userOp); + userOp.signature = this.invalidSignature || '0x00'; + await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( + SIG_VALIDATION_FAILURE, + ); + }); + }); + + describe('isValidSignatureWithSender', function () { + it('returns magic value for valid signature', async function () { + const message = 'Hello, world!'; + const hash = ethers.hashMessage(message); + const signature = await this.signer.signMessage(message); + await expect(this.mockFromAccount.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( + MAGIC_VALUE, + ); + }); + + it('returns failure value for invalid signature', async function () { + const hash = ethers.hashMessage('Hello, world!'); + const signature = this.invalidSignature || '0x00'; + await expect(this.mock.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( + INVALID_VALUE, + ); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC7579Module, + shouldBehaveLikeERC7579Validator, +}; diff --git a/test/account/modules/ERC7579Multisig.test.js b/test/account/modules/ERC7579Multisig.test.js new file mode 100644 index 00000000000..6d82f4cb415 --- /dev/null +++ b/test/account/modules/ERC7579Multisig.test.js @@ -0,0 +1,285 @@ +const { ethers, predeploy } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { impersonate } = require('../../helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { + MODULE_TYPE_EXECUTOR, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, + encodeMode, + encodeSingle, +} = require('../../helpers/erc7579'); +const { NonNativeSigner, MultiERC7913SigningKey } = require('../../helpers/signers'); +const { MAX_UINT64 } = require('../../helpers/constants'); + +const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); + +// Prepare signers in advance +const signerECDSA1 = ethers.Wallet.createRandom(); +const signerECDSA2 = ethers.Wallet.createRandom(); +const signerECDSA3 = ethers.Wallet.createRandom(); +const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer + +async function fixture() { + // Deploy ERC-7579 multisig module + const mock = await ethers.deployContract('$ERC7579MultisigExecutorMock', ['MultisigExecutor', '1']); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + + // Prepare signers + const signers = [signerECDSA1.address, signerECDSA2.address]; + const threshold = 1; + const multiSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); + + // Prepare module installation data + const installData = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'uint256'], [signers, threshold]); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + const mockAccountFromEntrypoint = await impersonate(predeploy.entrypoint.v08.target).then(asEntrypoint => + mockAccount.connect(asEntrypoint), + ); + + const moduleType = MODULE_TYPE_EXECUTOR; + + await mockAccount.deploy(); + + const args = [42, '0x1234']; + const data = target.interface.encodeFunctionData('mockFunctionWithArgs', args); + const calldata = encodeSingle(target, 0, data); + const mode = encodeMode({ callType: CALL_TYPE_CALL, execType: EXEC_TYPE_DEFAULT }); + + return { + moduleType, + mock, + mockAccount, + mockFromAccount, + mockAccountFromEntrypoint, + target, + installData, + args, + data, + calldata, + mode, + signers, + threshold, + multiSigner, + }; +} + +describe('ERC7579Multisig', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeERC7579Module(); + + it('sets initial signers and threshold on installation', async function () { + const tx = await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + + for (const signer of this.signers) { + await expect(tx) + .to.emit(this.mock, 'ERC7913SignerAdded') + .withArgs(this.mockAccount.address, signer.toLowerCase()); + } + + await expect(tx).to.emit(this.mock, 'ERC7913ThresholdSet').withArgs(this.mockAccount.address, this.threshold); + + // Verify signers and threshold + await expect(this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64)).to.eventually.deep.equal(this.signers); + await expect(this.mock.threshold(this.mockAccount.address)).to.eventually.equal(this.threshold); + + // onInstall is allowed again but is a noop + await this.mockFromAccount.onInstall( + ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'uint256'], [[signerECDSA3.address], 2]), + ); + + // Should still have the original signers and threshold + await expect(this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64)).to.eventually.deep.equal(this.signers); + await expect(this.mock.threshold(this.mockAccount.address)).to.eventually.equal(this.threshold); + }); + + it('cleans up signers and threshold on uninstallation', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + await this.mockAccountFromEntrypoint.uninstallModule(this.moduleType, this.mock.target, '0x'); + + // Verify signers and threshold are cleared + await expect(this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64)).to.eventually.deep.equal([]); + await expect(this.mock.threshold(this.mockAccount.address)).to.eventually.equal(0); + }); + + describe('signer management', function () { + beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + }); + + it('reverts adding an invalid signer', async function () { + await expect(this.mockFromAccount.addSigners(['0x1234'])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSigner') + .withArgs('0x1234'); + }); + + it('can add signers', async function () { + const newSigners = [signerECDSA3.address]; + + // Get signers before adding + const signersBefore = await this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64); + + // Add new signers + const tx = await this.mockFromAccount.addSigners(newSigners); + for (const signer of newSigners) { + await expect(tx) + .to.emit(this.mock, 'ERC7913SignerAdded') + .withArgs(this.mockAccount.address, signer.toLowerCase()); + } + + // Get signers after adding + const signersAfter = await this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64); + + // Check that new signers were added + expect(signersAfter.length).to.equal(signersBefore.length + 1); + expect(signersAfter.map(ethers.getAddress)).to.include(ethers.getAddress(signerECDSA3.address)); + + // Verify isSigner function + await expect(this.mock.isSigner(this.mockAccount.address, signerECDSA3.address)).to.eventually.be.true; + + // Reverts if the signer already exists + await expect(this.mockFromAccount.addSigners(newSigners)) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigAlreadyExists') + .withArgs(signerECDSA3.address.toLowerCase()); + }); + + it('can remove signers', async function () { + const removedSigners = [signerECDSA1.address].map(address => address.toLowerCase()); + + // Get signers before removing + const signersBefore = await this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64); + + // Remove signers + const tx = await this.mockFromAccount.removeSigners(removedSigners); + for (const signer of removedSigners) { + await expect(tx) + .to.emit(this.mock, 'ERC7913SignerRemoved') + .withArgs(this.mockAccount.address, signer.toLowerCase()); + } + + // Get signers after removing + const signersAfter = await this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64); + + // Check that signers were removed + expect(signersAfter.length).to.equal(signersBefore.length - 1); + expect(signersAfter).to.not.include(signerECDSA1.address); + + // Verify isSigner function + await expect(this.mock.isSigner(this.mockAccount.address, signerECDSA1.address)).to.eventually.be.false; + + // Reverts if the signer doesn't exist + await expect(this.mockFromAccount.removeSigners(removedSigners)) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigNonexistentSigner') + .withArgs(signerECDSA1.address.toLowerCase()); + + // Reverts if threshold becomes unreachable after removal + await this.mockFromAccount.setThreshold(1); + await expect(this.mockFromAccount.removeSigners([signerECDSA2.address])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigUnreachableThreshold') + .withArgs(0, 1); + }); + + it('can set threshold', async function () { + // Set threshold to 2 + await expect(this.mockFromAccount.setThreshold(2)) + .to.emit(this.mock, 'ERC7913ThresholdSet') + .withArgs(this.mockAccount.address, 2); + + // Verify threshold + await expect(this.mock.threshold(this.mockAccount.address)).to.eventually.equal(2); + + // Reverts if threshold is unreachable + await expect(this.mockFromAccount.setThreshold(3)) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigUnreachableThreshold') + .withArgs(2, 3); + }); + }); + + describe('signature validation', function () { + beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + }); + + it('validates multiple signatures meeting threshold', async function () { + // Set threshold to 2 + await this.mockFromAccount.setThreshold(2); + + // Create hash and sign it + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + const multiSignature = await this.multiSigner.signMessage(testMessage); + // Should succeed with valid signatures meeting threshold + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.true; + }); + + it('rejects signatures not meeting threshold', async function () { + // First set threshold to 2 + await this.mockFromAccount.setThreshold(2); + + // Create MultiERC7913SigningKey with one authorized signer + const multiSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1])); + + // Create hash and sign it + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + const multiSignature = await multiSigner.signMessage(testMessage); + + // Should fail because threshold is 2 but only 1 signature provided + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.false; + }); + + it('validates valid signatures meeting threshold', async function () { + // Create MultiERC7913SigningKey with one authorized signer + const multiSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1])); + + // Create hash and sign it + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + const multiSignature = await multiSigner.signMessage(testMessage); + + // Should succeed with valid signature meeting threshold + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.true; + }); + + it('rejects signatures from unauthorized signers', async function () { + // Create MultiERC7913SigningKey with unauthorized signer + const multiSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA4])); + + // Create hash and sign it + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + const multiSignature = await multiSigner.signMessage(testMessage); + + // Should fail because signer is not authorized + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.false; + }); + + it('rejects invalid signatures from authorized signers', async function () { + // Create hash and sign it with a different message + const testMessage = 'test'; + const differentMessage = 'different test'; + const messageHash = ethers.hashMessage(testMessage); + const multiSignature = await this.multiSigner.signMessage(differentMessage); + + // Should fail because signature is for a different hash + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.false; + }); + }); +}); diff --git a/test/account/modules/ERC7579MultisigWeighted.test.js b/test/account/modules/ERC7579MultisigWeighted.test.js new file mode 100644 index 00000000000..61989cfa1a3 --- /dev/null +++ b/test/account/modules/ERC7579MultisigWeighted.test.js @@ -0,0 +1,364 @@ +const { ethers, predeploy } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { impersonate } = require('../../helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { + MODULE_TYPE_EXECUTOR, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, + encodeMode, + encodeSingle, +} = require('../../helpers/erc7579'); +const { NonNativeSigner, MultiERC7913SigningKey } = require('../../helpers/signers'); +const { MAX_UINT64 } = require('../../helpers/constants'); + +const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); + +// Prepare signers in advance +const signerECDSA1 = ethers.Wallet.createRandom(); +const signerECDSA2 = ethers.Wallet.createRandom(); +const signerECDSA3 = ethers.Wallet.createRandom(); +const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer + +async function fixture() { + // Deploy ERC-7579 multisig weighted module + const mock = await ethers.deployContract('$ERC7579MultisigWeightedExecutorMock', ['MultisigWeightedExecutor', '1']); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + + // Prepare signers with weights + const signers = [signerECDSA1.address, signerECDSA2.address, signerECDSA3.address]; + const weights = [1, 2, 3]; // Different weights for each signer + const threshold = 3; // Set to 3 to match the default weights during initialization (3 signers × 1 weight = 3) + + // Create multi-signer instance + const multiSigner = new NonNativeSigner( + new MultiERC7913SigningKey([signerECDSA1, signerECDSA2, signerECDSA3], weights), + ); + + // Prepare module installation data + const installData = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes[]', 'uint256', 'uint256[]'], + [signers, threshold, weights], + ); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + const mockAccountFromEntrypoint = await impersonate(predeploy.entrypoint.v08.target).then(asEntrypoint => + mockAccount.connect(asEntrypoint), + ); + + const moduleType = MODULE_TYPE_EXECUTOR; + + await mockAccount.deploy(); + + const args = [42, '0x1234']; + const data = target.interface.encodeFunctionData('mockFunctionWithArgs', args); + const calldata = encodeSingle(target, 0, data); + const mode = encodeMode({ callType: CALL_TYPE_CALL, execType: EXEC_TYPE_DEFAULT }); + + return { + moduleType, + mock, + mockAccount, + mockFromAccount, + mockAccountFromEntrypoint, + target, + installData, + args, + data, + calldata, + mode, + signers, + weights, + threshold, + multiSigner, + }; +} + +describe('ERC7579MultisigWeighted', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeERC7579Module(); + + it('sets initial signers, weights, and threshold on installation', async function () { + const tx = await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + + for (const signer of this.signers) { + await expect(tx) + .to.emit(this.mock, 'ERC7913SignerAdded') + .withArgs(this.mockAccount.address, signer.toLowerCase()); + } + await expect(tx).to.emit(this.mock, 'ERC7913ThresholdSet').withArgs(this.mockAccount.address, this.threshold); + + // Verify signers and weights were set correctly + for (let i = 0; i < this.signers.length; i++) { + await expect(this.mock.signerWeight(this.mockAccount.address, this.signers[i])).to.eventually.equal( + this.weights[i], + ); + } + + // Verify threshold was set correctly + await expect(this.mock.threshold(this.mockAccount.address)).to.eventually.equal(this.threshold); + + // onInstall is allowed again but is a noop + const newSigners = [signerECDSA4.address]; + const newWeights = [5]; + const newThreshold = 10; + + await this.mockFromAccount.onInstall( + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes[]', 'uint256', 'uint256[]'], + [newSigners, newThreshold, newWeights], + ), + ); + + // Should still have the original signers, weights, and threshold + await expect(this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64)).to.eventually.deep.equal(this.signers); + + await expect(this.mock.threshold(this.mockAccount.address)).to.eventually.equal(this.threshold); + }); + + it('cleans up signers, weights, and threshold on uninstallation', async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + await this.mockAccountFromEntrypoint.uninstallModule(this.moduleType, this.mock.target, '0x'); + + // Verify signers and threshold are cleared + await expect(this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64)).to.eventually.deep.equal([]); + await expect(this.mock.threshold(this.mockAccount.address)).to.eventually.equal(0); + + // Verify weights are cleared (by checking a previously existing signer) + await expect(this.mock.signerWeight(this.mockAccount.address, this.signers[0])).to.eventually.equal(0); + }); + + describe('signer and weight management', function () { + beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + }); + + it('can add signers with default weight', async function () { + const newSigners = [signerECDSA4.address]; + + // Get signers before adding + const signersBefore = await this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64); + + // Add new signer + const tx = await this.mockFromAccount.addSigners(newSigners); + for (const signer of newSigners) { + await expect(tx) + .to.emit(this.mock, 'ERC7913SignerAdded') + .withArgs(this.mockAccount.address, signer.toLowerCase()); + } + + // Get signers after adding + const signersAfter = await this.mock.getSigners(this.mockAccount.address, 0, MAX_UINT64); + + // Check that new signer was added + expect(signersAfter.length).to.equal(signersBefore.length + 1); + expect(signersAfter.map(ethers.getAddress)).to.include(ethers.getAddress(signerECDSA4.address)); + + // Check that default weight is 1 + await expect(this.mock.signerWeight(this.mockAccount.address, signerECDSA4.address)).to.eventually.equal(1); + + // Check that total weight was updated + const totalWeight = await this.mock.totalWeight(this.mockAccount.address); + expect(totalWeight).to.equal(1 + 2 + 3 + 1); // Sum of all weights including new signer + }); + + it('can set signer weights', async function () { + // Set new weights for existing signers + const updateSigners = [this.signers[0], this.signers[1]]; + const newWeights = [5, 5]; + + await expect(this.mockFromAccount.setSignerWeights(updateSigners, newWeights)) + .to.emit(this.mock, 'ERC7579MultisigWeightChanged') + .withArgs(this.mockAccount.address, updateSigners[0].toLowerCase(), newWeights[0]) + .to.emit(this.mock, 'ERC7579MultisigWeightChanged') + .withArgs(this.mockAccount.address, updateSigners[1].toLowerCase(), newWeights[1]); + + // Verify new weights + await expect(this.mock.signerWeight(this.mockAccount.address, updateSigners[0])).to.eventually.equal( + newWeights[0], + ); + await expect(this.mock.signerWeight(this.mockAccount.address, updateSigners[1])).to.eventually.equal( + newWeights[1], + ); + + // Third signer weight should remain unchanged + await expect(this.mock.signerWeight(this.mockAccount.address, this.signers[2])).to.eventually.equal( + this.weights[2], + ); + + // Check total weight + await expect(this.mock.totalWeight(this.mockAccount.address)).to.eventually.equal(5 + 5 + 3); // Sum of all weights after update + }); + + it('cannot set weight to non-existent signer', async function () { + const randomSigner = ethers.Wallet.createRandom().address; + + // Reverts when setting weight for non-existent signer + await expect(this.mockFromAccount.setSignerWeights([randomSigner], [1])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigNonexistentSigner') + .withArgs(randomSigner.toLowerCase()); + }); + + it('cannot set weight to 0', async function () { + // Reverts when setting weight to 0 + await expect(this.mockFromAccount.setSignerWeights([this.signers[0]], [0])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidWeight') + .withArgs(this.signers[0].toLowerCase(), 0); + }); + + it('requires signers and weights arrays to have same length', async function () { + // Reverts when arrays have different lengths + await expect( + this.mockFromAccount.setSignerWeights([this.signers[0], this.signers[1]], [1]), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigMismatchedLength'); + }); + + it('can remove signers and updates total weight', async function () { + const removedSigner = this.signers[0].toLowerCase(); // weight = 1 + const weightBefore = await this.mock.totalWeight(this.mockAccount.address); + + // Remove signer + await expect(this.mockFromAccount.removeSigners([removedSigner])) + .to.emit(this.mock, 'ERC7913SignerRemoved') + .withArgs(this.mockAccount.address, removedSigner); + + // Check weight was updated + const weightAfter = await this.mock.totalWeight(this.mockAccount.address); + expect(weightAfter).to.equal(weightBefore - 1n); // Should be decreased by removed signer's weight + + // Cannot remove non-existent signer + await expect(this.mockFromAccount.removeSigners([removedSigner])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigNonexistentSigner') + .withArgs(removedSigner); + }); + + it('validates threshold is reachable when updating weights', async function () { + // Increase threshold to match total weight + const totalWeight = await this.mock.totalWeight(this.mockAccount.address); + + // Ensure totalWeight is what we expect (should be 6) + expect(totalWeight).to.equal(6); // 1+2+3 after weights are properly set + + // Set threshold to total weight + await this.mockFromAccount.setThreshold(totalWeight); + + // Now try to lower a weight, making total weight less than threshold + await expect(this.mockFromAccount.setSignerWeights([this.signers[2]], [1])) // Change weight from 3 to 1 + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigUnreachableThreshold') + .withArgs(totalWeight - 2n, totalWeight); // Total weight would be 2 less than threshold + }); + + it('prevents removing signers if threshold becomes unreachable', async function () { + // First check initial total weight + const initialTotalWeight = await this.mock.totalWeight(this.mockAccount.address); + expect(initialTotalWeight).to.equal(6); // 1+2+3 + + // Set threshold to current total weight + await this.mockFromAccount.setThreshold(initialTotalWeight); + + // Cannot remove a signer with weight > 0 as threshold would become unreachable + await expect(this.mockFromAccount.removeSigners([this.signers[0]])) // Weight 1 + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigUnreachableThreshold') + .withArgs(initialTotalWeight - 1n, initialTotalWeight); + }); + }); + + describe('signature validation with weights', function () { + beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); + }); + + it('validates signatures meeting threshold through combined weights', async function () { + // Threshold is 3, signerECDSA1(weight=1) + signerECDSA2(weight=2) = 3, which equals threshold + // Or just signerECDSA3(weight=3) alone is enough + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + + // Try with exactly the threshold weight (1+2=3 = threshold 3) + const exactSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); + const exactSignature = await exactSigner.signMessage(testMessage); + + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, exactSignature)).to + .eventually.be.true; + + // Also works with all signers (1+2+3=6 > threshold 3) + const sufficientSignature = await this.multiSigner.signMessage(testMessage); + + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, sufficientSignature)).to + .eventually.be.true; + + // Also try with just signerECDSA3 (weight 3) = 3, exactly meeting threshold + const minimumSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA3])); + const minimumSignature = await minimumSigner.signMessage(testMessage); + + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, minimumSignature)).to + .eventually.be.true; + }); + + it('rejects signatures that collectively miss threshold', async function () { + // Increase threshold to 4 (more than the default total of 3) + await this.mockFromAccount.setThreshold(4); + + // Single signer with weight 1 is insufficient + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + const insufficientSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1])); + const insufficientSignature = await insufficientSigner.signMessage(testMessage); + + // Should fail because total weight (1) < threshold (4) + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, insufficientSignature)).to + .eventually.be.false; + }); + + it('considers weight changes when validating signatures', async function () { + // Increase threshold to 4 + await this.mockFromAccount.setThreshold(4); + + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + + // Create signer with just signerECDSA1 + signerECDSA2 (weight 1+2=3 < threshold 4) + const insufficientSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); + const insufficientSignature = await insufficientSigner.signMessage(testMessage); + + // First verify this combination is insufficient + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, insufficientSignature)).to + .eventually.be.false; + + // Now increase the weight of signerECDSA2 to make it sufficient + await this.mockFromAccount.setSignerWeights([this.signers[1]], [3]); // Now weight is 1+3=4 >= threshold 4 + + // Same signature should now pass + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, insufficientSignature)).to + .eventually.be.true; + }); + + it('rejects invalid signatures regardless of weight', async function () { + // Even with a high weight, invalid signatures should be rejected + await this.mockFromAccount.setSignerWeights([this.signers[0]], [10]); // Very high weight + + const testMessage = 'test'; + const differentMessage = 'different test'; + const messageHash = ethers.hashMessage(testMessage); + + // Sign the wrong message + const invalidSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1])); + const invalidSignature = await invalidSigner.signMessage(differentMessage); + + // Should fail because signature is invalid for the hash + await expect(this.mock.$_rawERC7579Validation(this.mockAccount.address, messageHash, invalidSignature)).to + .eventually.be.false; + }); + }); +}); diff --git a/test/account/modules/ERC7579SignatureValidator.test.js b/test/account/modules/ERC7579SignatureValidator.test.js new file mode 100644 index 00000000000..cf09a48bf48 --- /dev/null +++ b/test/account/modules/ERC7579SignatureValidator.test.js @@ -0,0 +1,151 @@ +const { ethers, predeploy } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { expect } = require('chai'); + +const { impersonate } = require('../../helpers/account'); +const { getDomain, PackedUserOperation } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { MODULE_TYPE_VALIDATOR } = require('../../helpers/erc7579'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../../helpers/signers'); + +const { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Validator } = require('./ERC7579Module.behavior'); + +// Prepare signers in advance (RSA are long to initialize) +const signerECDSA = ethers.Wallet.createRandom(); +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 signature validator + const mock = await ethers.deployContract('$ERC7579Signature'); + + // ERC-7913 verifiers + const verifierP256 = await ethers.deployContract('ERC7913P256Verifier'); + const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(predeploy.entrypoint.v08); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + + return { + moduleType: MODULE_TYPE_VALIDATOR, + mock, + verifierP256, + verifierRSA, + mockFromAccount, + entrypointDomain, + mockAccount, + other, + }; +} + +function prepareSigner(prototype) { + this.signUserOp = userOp => + prototype.signTypedData + .call(this.signer, this.entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); +} + +describe('ERC7579Signature', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('reverts with ERC7579SignatureInvalidSignerLength when signer length is less than 20 bytes', async function () { + const shortSigner = '0x0123456789'; // Less than 20 bytes + await expect(this.mockFromAccount.onInstall(shortSigner)).to.be.revertedWithCustomError( + this.mock, + 'ERC7579SignatureInvalidSignerLength', + ); + }); + + it('behaves as a noop when the validator is already installed for an account', async function () { + // First installation should succeed + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await expect(this.mockFromAccount.onInstall(signerData)).to.not.be.reverted; + + // Second installation should behave as a no-op + await this.mockFromAccount.onInstall(ethers.solidityPacked(['address'], [ethers.Wallet.createRandom().address])); // Not revert + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); // No change in signers + }); + + it('emits event on ERC7579SignatureSignerSet on both installation and uninstallation', async function () { + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + + // First install + await expect(this.mockFromAccount.onInstall(signerData)) + .to.emit(this.mock, 'ERC7579SignatureSignerSet') + .withArgs(this.mockAccount.address, signerData); + + // Then uninstall + await expect(this.mockFromAccount.onUninstall('0x')) + .to.emit(this.mock, 'ERC7579SignatureSignerSet') + .withArgs(this.mockAccount.address, '0x'); + }); + + it('returns the correct signer bytes when set', async function () { + // Starts empty + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal('0x'); + + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await this.mockFromAccount.onInstall(signerData); + + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); + }); + + it('sets signer correctly with setSigner and emits event', async function () { + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await expect(this.mockFromAccount.setSigner(signerData)) + .to.emit(this.mockFromAccount, 'ERC7579SignatureSignerSet') + .withArgs(this.mockAccount.address, signerData); + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); + }); + + it('reverts when calling setSigner with invalid signer length', async function () { + await expect(this.mock.setSigner('0x0123456789')).to.be.revertedWithCustomError( + this.mock, + 'ERC7579SignatureInvalidSignerLength', + ); + }); + + // ECDSA tested in ./ERC7579Validator.test.js + + describe('P256 key', function () { + beforeEach(async function () { + this.signer = signerP256; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); + this.installData = ethers.concat([ + this.verifierP256.target, + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); + + describe('RSA key', function () { + beforeEach(async function () { + this.signer = signerRSA; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); + this.installData = ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [this.signer.signingKey.publicKey.e, this.signer.signingKey.publicKey.n], + ), + ]); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); +}); diff --git a/test/account/modules/ERC7579Validator.test.js b/test/account/modules/ERC7579Validator.test.js new file mode 100644 index 00000000000..83a6539da3d --- /dev/null +++ b/test/account/modules/ERC7579Validator.test.js @@ -0,0 +1,57 @@ +const { ethers, predeploy } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { impersonate } = require('../../helpers/account'); +const { getDomain, PackedUserOperation } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { MODULE_TYPE_VALIDATOR } = require('../../helpers/erc7579'); + +const { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Validator } = require('./ERC7579Module.behavior'); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 validator module + const mock = await ethers.deployContract('$ERC7579Signature'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(predeploy.entrypoint.v08); + + // Prepare signer + const signer = ethers.Wallet.createRandom(); + const signUserOp = userOp => + signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + // Prepare module installation data + const installData = ethers.solidityPacked(['address'], [signer.address]); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + + return { + moduleType: MODULE_TYPE_VALIDATOR, + mock, + mockFromAccount, + mockAccount, + other, + signer, + signUserOp, + installData, + }; +} + +describe('ERC7579Validator', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('ECDSA key', function () { + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); +}); diff --git a/test/helpers/enums.js b/test/helpers/enums.js index 6adbf64ad82..703615895ef 100644 --- a/test/helpers/enums.js +++ b/test/helpers/enums.js @@ -11,4 +11,5 @@ module.exports = { Rounding: EnumTyped('Floor', 'Ceil', 'Trunc', 'Expand'), OperationState: Enum('Unset', 'Waiting', 'Ready', 'Done'), RevertType: EnumTyped('None', 'RevertWithoutMessage', 'RevertWithMessage', 'RevertWithCustomError', 'Panic'), + ERC7579OperationState: Enum('Unknown', 'Scheduled', 'Ready', 'Expired', 'Executed', 'Canceled'), };