From 31d4075abc9eff3a7271e87a7d7e8315877010b1 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 26 Aug 2025 10:51:26 -1000 Subject: [PATCH 1/7] Add ERC7579 modules --- .changeset/free-waves-draw.md | 5 + .changeset/pink-loops-jump.md | 5 + .changeset/solid-squids-cough.md | 5 + .changeset/weak-chefs-open.md | 5 + .changeset/wild-masks-worry.md | 5 + .changeset/yummy-ideas-stay.md | 5 + contracts/account/README.adoc | 24 + .../modules/ERC7579DelayedExecutor.sol | 443 ++++++++++++++++++ contracts/account/modules/ERC7579Executor.sol | 93 ++++ contracts/account/modules/ERC7579Multisig.sol | 285 +++++++++++ .../modules/ERC7579MultisigWeighted.sol | 232 +++++++++ .../account/modules/ERC7579Signature.sol | 86 ++++ .../account/modules/ERC7579Validator.sol | 101 ++++ .../account/modules/ERC7579ExecutorMocks.sol | 31 ++ .../account/modules/ERC7579MultisigMocks.sol | 74 +++ .../mocks/docs/account/MyAccountERC7579.sol | 15 + .../MyERC7579DelayedSocialRecovery.sol | 57 +++ .../docs/account/modules/MyERC7579Modules.sol | 20 + .../modules/MyERC7579SocialRecovery.sol | 44 ++ docs/modules/ROOT/nav.adoc | 1 + docs/modules/ROOT/pages/account-modules.adoc | 128 +++++ .../modules/ERC7579DelayedExecutor.test.js | 300 ++++++++++++ test/account/modules/ERC7579Executor.test.js | 74 +++ .../account/modules/ERC7579Module.behavior.js | 70 +++ test/account/modules/ERC7579Multisig.test.js | 285 +++++++++++ .../modules/ERC7579MultisigWeighted.test.js | 364 ++++++++++++++ .../modules/ERC7579SignatureValidator.test.js | 151 ++++++ test/account/modules/ERC7579Validator.test.js | 57 +++ test/helpers/enums.js | 1 + 29 files changed, 2966 insertions(+) create mode 100644 .changeset/free-waves-draw.md create mode 100644 .changeset/pink-loops-jump.md create mode 100644 .changeset/solid-squids-cough.md create mode 100644 .changeset/weak-chefs-open.md create mode 100644 .changeset/wild-masks-worry.md create mode 100644 .changeset/yummy-ideas-stay.md create mode 100644 contracts/account/modules/ERC7579DelayedExecutor.sol create mode 100644 contracts/account/modules/ERC7579Executor.sol create mode 100644 contracts/account/modules/ERC7579Multisig.sol create mode 100644 contracts/account/modules/ERC7579MultisigWeighted.sol create mode 100644 contracts/account/modules/ERC7579Signature.sol create mode 100644 contracts/account/modules/ERC7579Validator.sol create mode 100644 contracts/mocks/account/modules/ERC7579ExecutorMocks.sol create mode 100644 contracts/mocks/account/modules/ERC7579MultisigMocks.sol create mode 100644 contracts/mocks/docs/account/MyAccountERC7579.sol create mode 100644 contracts/mocks/docs/account/modules/MyERC7579DelayedSocialRecovery.sol create mode 100644 contracts/mocks/docs/account/modules/MyERC7579Modules.sol create mode 100644 contracts/mocks/docs/account/modules/MyERC7579SocialRecovery.sol create mode 100644 docs/modules/ROOT/pages/account-modules.adoc create mode 100644 test/account/modules/ERC7579DelayedExecutor.test.js create mode 100644 test/account/modules/ERC7579Executor.test.js create mode 100644 test/account/modules/ERC7579Module.behavior.js create mode 100644 test/account/modules/ERC7579Multisig.test.js create mode 100644 test/account/modules/ERC7579MultisigWeighted.test.js create mode 100644 test/account/modules/ERC7579SignatureValidator.test.js create mode 100644 test/account/modules/ERC7579Validator.test.js 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/MyAccountERC7579.sol b/contracts/mocks/docs/account/MyAccountERC7579.sol new file mode 100644 index 00000000000..acc9e5c9910 --- /dev/null +++ b/contracts/mocks/docs/account/MyAccountERC7579.sol @@ -0,0 +1,15 @@ +// contracts/MyAccountERC7579.sol +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {AccountERC7579} from "../../../account/extensions/draft-AccountERC7579.sol"; +import {IERC1271} from "../../../interfaces/IERC1271.sol"; +import {MODULE_TYPE_VALIDATOR} from "../../../interfaces/draft-IERC7579.sol"; +import {Initializable} from "../../../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); + } +} 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..71a2e861b8c --- /dev/null +++ b/docs/modules/ROOT/pages/account-modules.adoc @@ -0,0 +1,128 @@ += 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] +---- +include::api:example$account/MyAccountERC7579.sol[] +---- + +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'), }; From e581eb6632006a539c4142c98845d91b3751e007 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 09:27:17 -1000 Subject: [PATCH 2/7] up --- contracts/mocks/docs/account/MyAccountERC7579.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/mocks/docs/account/MyAccountERC7579.sol b/contracts/mocks/docs/account/MyAccountERC7579.sol index acc9e5c9910..5356c973564 100644 --- a/contracts/mocks/docs/account/MyAccountERC7579.sol +++ b/contracts/mocks/docs/account/MyAccountERC7579.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import {AccountERC7579} from "../../../account/extensions/draft-AccountERC7579.sol"; -import {IERC1271} from "../../../interfaces/IERC1271.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; import {MODULE_TYPE_VALIDATOR} from "../../../interfaces/draft-IERC7579.sol"; import {Initializable} from "../../../proxy/utils/Initializable.sol"; From 3b2717b77e9ef398818095ec12725a4d8df763df Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 09:36:02 -1000 Subject: [PATCH 3/7] Revert "up" This reverts commit e581eb6632006a539c4142c98845d91b3751e007. --- contracts/mocks/docs/account/MyAccountERC7579.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/mocks/docs/account/MyAccountERC7579.sol b/contracts/mocks/docs/account/MyAccountERC7579.sol index 5356c973564..acc9e5c9910 100644 --- a/contracts/mocks/docs/account/MyAccountERC7579.sol +++ b/contracts/mocks/docs/account/MyAccountERC7579.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import {AccountERC7579} from "../../../account/extensions/draft-AccountERC7579.sol"; -import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {IERC1271} from "../../../interfaces/IERC1271.sol"; import {MODULE_TYPE_VALIDATOR} from "../../../interfaces/draft-IERC7579.sol"; import {Initializable} from "../../../proxy/utils/Initializable.sol"; From 812d4d0a9f626962ce777574986b70d1f99d710c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 09:37:22 -1000 Subject: [PATCH 4/7] Patch MyAccountERC7579 docs mock --- scripts/upgradeable/upgradeable.patch | 39 ++++++++++++++++++--------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/scripts/upgradeable/upgradeable.patch b/scripts/upgradeable/upgradeable.patch index 0fd5caa052b..63697ce3a6f 100644 --- a/scripts/upgradeable/upgradeable.patch +++ b/scripts/upgradeable/upgradeable.patch @@ -59,10 +59,10 @@ index ff596b0c3..000000000 - - diff --git a/README.md b/README.md -index 60d0a430a..0e4f91a6d 100644 +index 2f92281b3..a0e46695d 100644 --- a/README.md +++ b/README.md -@@ -19,6 +19,9 @@ +@@ -20,6 +20,9 @@ > [!IMPORTANT] > OpenZeppelin Contracts uses semantic versioning to communicate backwards compatibility of its API and storage layout. For upgradeable contracts, the storage layout of different major versions should be assumed incompatible, for example, it is unsafe to upgrade from 4.9.3 to 5.0.0. Learn more at [Backwards Compatibility](https://docs.openzeppelin.com/contracts/backwards-compatibility). @@ -72,7 +72,7 @@ index 60d0a430a..0e4f91a6d 100644 ## Overview ### Installation -@@ -26,7 +29,7 @@ +@@ -27,7 +30,7 @@ #### Hardhat (npm) ``` @@ -81,7 +81,7 @@ index 60d0a430a..0e4f91a6d 100644 ``` #### Foundry (git) -@@ -38,10 +41,10 @@ $ npm install @openzeppelin/contracts +@@ -39,10 +42,10 @@ $ npm install @openzeppelin/contracts > Foundry installs the latest version initially, but subsequent `forge update` commands will use the `master` branch. ``` @@ -94,7 +94,7 @@ index 60d0a430a..0e4f91a6d 100644 ### Usage -@@ -50,10 +53,11 @@ Once installed, you can use the contracts in the library by importing them: +@@ -51,10 +54,11 @@ Once installed, you can use the contracts in the library by importing them: ```solidity pragma solidity ^0.8.20; @@ -109,8 +109,21 @@ index 60d0a430a..0e4f91a6d 100644 } } ``` +diff --git a/contracts/mocks/docs/account/MyAccountERC7579.sol b/contracts/mocks/docs/account/MyAccountERC7579.sol +index acc9e5c99..5356c9735 100644 +--- a/contracts/mocks/docs/account/MyAccountERC7579.sol ++++ b/contracts/mocks/docs/account/MyAccountERC7579.sol +@@ -3,7 +3,7 @@ + pragma solidity ^0.8.27; + + import {AccountERC7579} from "../../../account/extensions/draft-AccountERC7579.sol"; +-import {IERC1271} from "../../../interfaces/IERC1271.sol"; ++import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; + import {MODULE_TYPE_VALIDATOR} from "../../../interfaces/draft-IERC7579.sol"; + import {Initializable} from "../../../proxy/utils/Initializable.sol"; + diff --git a/contracts/package.json b/contracts/package.json -index 70ae73bc2..ef659873f 100644 +index 8ccb9465e..509cd7f05 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,5 +1,5 @@ @@ -118,7 +131,7 @@ index 70ae73bc2..ef659873f 100644 - "name": "@openzeppelin/contracts", + "name": "@openzeppelin/contracts-upgradeable", "description": "Secure Smart Contract library for Solidity", - "version": "5.3.0", + "version": "5.4.0", "files": [ @@ -13,7 +13,7 @@ }, @@ -140,11 +153,11 @@ index 70ae73bc2..ef659873f 100644 + } } diff --git a/contracts/utils/cryptography/EIP712.sol b/contracts/utils/cryptography/EIP712.sol -index c39954e35..fe681f87a 100644 +index 0eaef9d27..01f1b5f58 100644 --- a/contracts/utils/cryptography/EIP712.sol +++ b/contracts/utils/cryptography/EIP712.sol @@ -4,7 +4,6 @@ - pragma solidity ^0.8.20; + pragma solidity ^0.8.24; import {MessageHashUtils} from "./MessageHashUtils.sol"; -import {ShortStrings, ShortString} from "../ShortStrings.sol"; @@ -314,10 +327,10 @@ index c39954e35..fe681f87a 100644 } } diff --git a/package.json b/package.json -index eeeaf0bcd..65581c544 100644 +index 285a074ed..d48cc8e4b 100644 --- a/package.json +++ b/package.json -@@ -34,7 +34,7 @@ +@@ -35,7 +35,7 @@ }, "repository": { "type": "git", @@ -335,7 +348,7 @@ index 304d1386a..a1cd63bee 100644 +@openzeppelin/contracts-upgradeable/=contracts/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ diff --git a/test/account/AccountERC7702.test.js b/test/account/AccountERC7702.test.js -index d08a52209..7a44bccfe 100644 +index f442d49af..8f22dc926 100644 --- a/test/account/AccountERC7702.test.js +++ b/test/account/AccountERC7702.test.js @@ -26,8 +26,8 @@ async function fixture() { @@ -350,7 +363,7 @@ index d08a52209..7a44bccfe 100644 verifyingContract: mock.address, }; diff --git a/test/account/examples/AccountERC7702WithModulesMock.test.js b/test/account/examples/AccountERC7702WithModulesMock.test.js -index 9ee5f9177..f6106bcc7 100644 +index 8ceab19d1..c3f4194a6 100644 --- a/test/account/examples/AccountERC7702WithModulesMock.test.js +++ b/test/account/examples/AccountERC7702WithModulesMock.test.js @@ -36,8 +36,8 @@ async function fixture() { From ab2a70da2c8b6ad8237818b08bc313626b7b5e3e Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 09:41:55 -1000 Subject: [PATCH 5/7] Import IERC1271 from draft-AccountERC7579 in docs mock --- contracts/mocks/docs/account/MyAccountERC7579.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/contracts/mocks/docs/account/MyAccountERC7579.sol b/contracts/mocks/docs/account/MyAccountERC7579.sol index acc9e5c9910..7f3bbf79041 100644 --- a/contracts/mocks/docs/account/MyAccountERC7579.sol +++ b/contracts/mocks/docs/account/MyAccountERC7579.sol @@ -2,8 +2,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import {AccountERC7579} from "../../../account/extensions/draft-AccountERC7579.sol"; -import {IERC1271} from "../../../interfaces/IERC1271.sol"; +import {AccountERC7579, IERC1271} from "../../../account/extensions/draft-AccountERC7579.sol"; import {MODULE_TYPE_VALIDATOR} from "../../../interfaces/draft-IERC7579.sol"; import {Initializable} from "../../../proxy/utils/Initializable.sol"; From 2a041f38cb73c251e1952a69d6f4334f99d6f554 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 09:42:34 -1000 Subject: [PATCH 6/7] Revert "Patch MyAccountERC7579 docs mock" This reverts commit 812d4d0a9f626962ce777574986b70d1f99d710c. --- scripts/upgradeable/upgradeable.patch | 39 +++++++++------------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/scripts/upgradeable/upgradeable.patch b/scripts/upgradeable/upgradeable.patch index 63697ce3a6f..0fd5caa052b 100644 --- a/scripts/upgradeable/upgradeable.patch +++ b/scripts/upgradeable/upgradeable.patch @@ -59,10 +59,10 @@ index ff596b0c3..000000000 - - diff --git a/README.md b/README.md -index 2f92281b3..a0e46695d 100644 +index 60d0a430a..0e4f91a6d 100644 --- a/README.md +++ b/README.md -@@ -20,6 +20,9 @@ +@@ -19,6 +19,9 @@ > [!IMPORTANT] > OpenZeppelin Contracts uses semantic versioning to communicate backwards compatibility of its API and storage layout. For upgradeable contracts, the storage layout of different major versions should be assumed incompatible, for example, it is unsafe to upgrade from 4.9.3 to 5.0.0. Learn more at [Backwards Compatibility](https://docs.openzeppelin.com/contracts/backwards-compatibility). @@ -72,7 +72,7 @@ index 2f92281b3..a0e46695d 100644 ## Overview ### Installation -@@ -27,7 +30,7 @@ +@@ -26,7 +29,7 @@ #### Hardhat (npm) ``` @@ -81,7 +81,7 @@ index 2f92281b3..a0e46695d 100644 ``` #### Foundry (git) -@@ -39,10 +42,10 @@ $ npm install @openzeppelin/contracts +@@ -38,10 +41,10 @@ $ npm install @openzeppelin/contracts > Foundry installs the latest version initially, but subsequent `forge update` commands will use the `master` branch. ``` @@ -94,7 +94,7 @@ index 2f92281b3..a0e46695d 100644 ### Usage -@@ -51,10 +54,11 @@ Once installed, you can use the contracts in the library by importing them: +@@ -50,10 +53,11 @@ Once installed, you can use the contracts in the library by importing them: ```solidity pragma solidity ^0.8.20; @@ -109,21 +109,8 @@ index 2f92281b3..a0e46695d 100644 } } ``` -diff --git a/contracts/mocks/docs/account/MyAccountERC7579.sol b/contracts/mocks/docs/account/MyAccountERC7579.sol -index acc9e5c99..5356c9735 100644 ---- a/contracts/mocks/docs/account/MyAccountERC7579.sol -+++ b/contracts/mocks/docs/account/MyAccountERC7579.sol -@@ -3,7 +3,7 @@ - pragma solidity ^0.8.27; - - import {AccountERC7579} from "../../../account/extensions/draft-AccountERC7579.sol"; --import {IERC1271} from "../../../interfaces/IERC1271.sol"; -+import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; - import {MODULE_TYPE_VALIDATOR} from "../../../interfaces/draft-IERC7579.sol"; - import {Initializable} from "../../../proxy/utils/Initializable.sol"; - diff --git a/contracts/package.json b/contracts/package.json -index 8ccb9465e..509cd7f05 100644 +index 70ae73bc2..ef659873f 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,5 +1,5 @@ @@ -131,7 +118,7 @@ index 8ccb9465e..509cd7f05 100644 - "name": "@openzeppelin/contracts", + "name": "@openzeppelin/contracts-upgradeable", "description": "Secure Smart Contract library for Solidity", - "version": "5.4.0", + "version": "5.3.0", "files": [ @@ -13,7 +13,7 @@ }, @@ -153,11 +140,11 @@ index 8ccb9465e..509cd7f05 100644 + } } diff --git a/contracts/utils/cryptography/EIP712.sol b/contracts/utils/cryptography/EIP712.sol -index 0eaef9d27..01f1b5f58 100644 +index c39954e35..fe681f87a 100644 --- a/contracts/utils/cryptography/EIP712.sol +++ b/contracts/utils/cryptography/EIP712.sol @@ -4,7 +4,6 @@ - pragma solidity ^0.8.24; + pragma solidity ^0.8.20; import {MessageHashUtils} from "./MessageHashUtils.sol"; -import {ShortStrings, ShortString} from "../ShortStrings.sol"; @@ -327,10 +314,10 @@ index 0eaef9d27..01f1b5f58 100644 } } diff --git a/package.json b/package.json -index 285a074ed..d48cc8e4b 100644 +index eeeaf0bcd..65581c544 100644 --- a/package.json +++ b/package.json -@@ -35,7 +35,7 @@ +@@ -34,7 +34,7 @@ }, "repository": { "type": "git", @@ -348,7 +335,7 @@ index 304d1386a..a1cd63bee 100644 +@openzeppelin/contracts-upgradeable/=contracts/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ diff --git a/test/account/AccountERC7702.test.js b/test/account/AccountERC7702.test.js -index f442d49af..8f22dc926 100644 +index d08a52209..7a44bccfe 100644 --- a/test/account/AccountERC7702.test.js +++ b/test/account/AccountERC7702.test.js @@ -26,8 +26,8 @@ async function fixture() { @@ -363,7 +350,7 @@ index f442d49af..8f22dc926 100644 verifyingContract: mock.address, }; diff --git a/test/account/examples/AccountERC7702WithModulesMock.test.js b/test/account/examples/AccountERC7702WithModulesMock.test.js -index 8ceab19d1..c3f4194a6 100644 +index 9ee5f9177..f6106bcc7 100644 --- a/test/account/examples/AccountERC7702WithModulesMock.test.js +++ b/test/account/examples/AccountERC7702WithModulesMock.test.js @@ -36,8 +36,8 @@ async function fixture() { From 16e9e2c723ebd53530660b10585bd7dfc3bc2619 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 09:50:59 -1000 Subject: [PATCH 7/7] Inline MyAccountERC7579 in docs --- .../mocks/docs/account/MyAccountERC7579.sol | 14 -------------- docs/modules/ROOT/pages/account-modules.adoc | 16 +++++++++++++++- 2 files changed, 15 insertions(+), 15 deletions(-) delete mode 100644 contracts/mocks/docs/account/MyAccountERC7579.sol diff --git a/contracts/mocks/docs/account/MyAccountERC7579.sol b/contracts/mocks/docs/account/MyAccountERC7579.sol deleted file mode 100644 index 7f3bbf79041..00000000000 --- a/contracts/mocks/docs/account/MyAccountERC7579.sol +++ /dev/null @@ -1,14 +0,0 @@ -// contracts/MyAccountERC7579.sol -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import {AccountERC7579, IERC1271} from "../../../account/extensions/draft-AccountERC7579.sol"; -import {MODULE_TYPE_VALIDATOR} from "../../../interfaces/draft-IERC7579.sol"; -import {Initializable} from "../../../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); - } -} diff --git a/docs/modules/ROOT/pages/account-modules.adoc b/docs/modules/ROOT/pages/account-modules.adoc index 71a2e861b8c..f35bda56084 100644 --- a/docs/modules/ROOT/pages/account-modules.adoc +++ b/docs/modules/ROOT/pages/account-modules.adoc @@ -12,7 +12,21 @@ OpenZeppelin offers an implementation of an xref:api:account.adoc#AccountERC7579 [source,solidity] ---- -include::api:example$account/MyAccountERC7579.sol[] +// 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.