From 59870afb52649144874844d17b9db1963adef4c5 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 27 Aug 2025 10:05:23 -1000 Subject: [PATCH] Add ERC7579 Executors --- .changeset/free-waves-draw.md | 5 + .changeset/weak-chefs-open.md | 5 + contracts/account/README.adoc | 10 + .../modules/ERC7579DelayedExecutor.sol | 443 ++++++++++++++++++ contracts/account/modules/ERC7579Executor.sol | 93 ++++ .../account/modules/ERC7579ExecutorMocks.sol | 31 ++ .../modules/ERC7579DelayedExecutor.test.js | 300 ++++++++++++ test/account/modules/ERC7579Executor.test.js | 74 +++ .../account/modules/ERC7579Module.behavior.js | 70 +++ test/helpers/enums.js | 1 + 10 files changed, 1032 insertions(+) create mode 100644 .changeset/free-waves-draw.md create mode 100644 .changeset/weak-chefs-open.md create mode 100644 contracts/account/modules/ERC7579DelayedExecutor.sol create mode 100644 contracts/account/modules/ERC7579Executor.sol create mode 100644 contracts/mocks/account/modules/ERC7579ExecutorMocks.sol 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 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/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/contracts/account/README.adoc b/contracts/account/README.adoc index dc3c9a010a7..f575ae69410 100644 --- a/contracts/account/README.adoc +++ b/contracts/account/README.adoc @@ -7,6 +7,8 @@ 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. * {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 +25,14 @@ This directory includes contracts to build accounts for ERC-4337. These include: {{ERC7821}} +== Modules + +=== Executors + +{{ERC7579Executor}} + +{{ERC7579DelayedExecutor}} + == 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/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/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/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'), };