diff --git a/contracts/account/README.adoc b/contracts/account/README.adoc index 14cd223b..f5b45bd3 100644 --- a/contracts/account/README.adoc +++ b/contracts/account/README.adoc @@ -10,6 +10,11 @@ This directory includes contracts to build accounts for ERC-4337. These include: * {ERC7821}: Minimal batch executor implementation contracts. Useful to enable easy batch execution for smart contracts. * {ERC7579Validator}: Abstract validator module for ERC-7579 accounts that provides base implementation for signature validation. * {ERC7579SignatureValidator}: Implementation of ERC7579Validator using ERC-7913 signature verification for address-less cryptographic keys. + * {ERC7579Multisig}: An abstract multisig module for ERC-7579 accounts using ERC-7913 signer keys. + * {ERC7579MultisigWeighted}: An abstract weighted multisig module that allows different weights to be assigned to signers. + * {ERC7579MultisigConfirmation}: An abstract confirmation-based multisig module that each signer to provide a confirmation signature. + * {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. * {PaymasterCore}: An ERC-4337 paymaster implementation that includes the core logic to validate and pay for user operations. * {PaymasterERC20}: A paymaster that allows users to pay for user operations using ERC-20 tokens. * {PaymasterERC20Guarantor}: A paymaster that enables third parties to guarantee user operations by pre-funding gas costs, with the option for users to repay or for guarantors to absorb the cost. @@ -30,6 +35,18 @@ This directory includes contracts to build accounts for ERC-4337. These include: == Modules +{{ERC7579Multisig}} + +{{ERC7579MultisigWeighted}} + +{{ERC7579MultisigConfirmation}} + +=== Executors + +{{ERC7579Executor}} + +{{ERC7579DelayedExecutor}} + === Validators {{ERC7579Validator}} diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol new file mode 100644 index 00000000..256ec1a3 --- /dev/null +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; +import {IERC7579ModuleConfig, MODULE_TYPE_EXECUTOR} from "@openzeppelin/contracts/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. + * + * 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 operation is not authorized to be canceled. + error ERC7579ExecutorUnauthorizedCancellation(); + + /// @dev The operation is not authorized to be scheduled. + error ERC7579ExecutorUnauthorizedSchedule(); + + /// @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()); + bool allowed = _validateSchedule(account, salt, mode, data); + (uint32 executableAfter, , ) = getDelay(account); + _scheduleAt(account, salt, mode, data, Time.timestamp(), executableAfter); + require(allowed, ERC7579ExecutorUnauthorizedSchedule()); + } + + /** + * @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 { + bool allowed = _validateCancel(account, salt, mode, data); + _cancel(account, mode, data, salt); // Prioritize errors thrown in _cancel + require(allowed, ERC7579ExecutorUnauthorizedCancellation()); + } + + /** + * @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. + * + * WARNING: The account's delay will be removed if the account calls this function, allowing + * immediate scheduling of operations. As an account operator, make sure to uninstall to a + * predefined path in your account that properly handles the side effects of uninstallation. + * See {AccountERC7579-uninstallModule}. + */ + function onUninstall(bytes calldata) public virtual { + _config[msg.sender].installed = false; + _setDelay(msg.sender, 0, minSetback()); // Avoids immediate downgrades + _setExpiration(msg.sender, 0); + } + + /// @inheritdoc ERC7579Executor + function _validateExecution( + address /* account */, + bytes32 /* salt */, + bytes32 /* mode */, + bytes calldata data + ) internal view virtual override returns (bool valid, bytes calldata executionCalldata) { + return (true, data); // Anyone can execute, the state validation of the operation is enough + } + + /** + * @dev Whether the caller is authorized to cancel operations. + * By default, this checks if the caller is the account itself. Derived contracts can + * override this to implement custom authorization logic. + * + * Example extension: + * + * ```solidity + * function _validateCancel( + * address account, + * bytes32 mode, + * bytes calldata data, + * bytes32 salt + * ) internal view override returns (bool) { + * bool isAuthorized = ...; // custom logic to check authorization + * return isAuthorized || super._validateCancel(account, mode, data, salt); + * } + *``` + */ + function _validateCancel( + address account, + bytes32 /* salt */, + bytes32 /* mode */, + bytes calldata /* data */ + ) internal view virtual returns (bool) { + return account == msg.sender; + } + + /** + * @dev Whether the caller is authorized to schedule operations. + * By default, this checks if the caller is the account itself. Derived contracts can + * override this to implement custom authorization logic. + * + * Example extension: + * + * ```solidity + * function _validateSchedule( + * address account, + * bytes32 mode, + * bytes calldata data, + * bytes32 salt + * ) internal view override returns (bool) { + * bool isAuthorized = ...; // custom logic to check authorization + * return isAuthorized || super._validateSchedule(account, mode, data, salt); + * } + *``` + */ + function _validateSchedule( + address account, + bytes32 /* salt */, + bytes32 /* mode */, + bytes calldata /* data */ + ) internal view virtual returns (bool) { + return account == msg.sender; + } + + /** + * @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 00000000..955e0cea --- /dev/null +++ b/contracts/account/modules/ERC7579Executor.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IERC7579Module, MODULE_TYPE_EXECUTOR, IERC7579Execution} from "@openzeppelin/contracts/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 + ); + + /// @dev Thrown when the execution is invalid. See {_validateExecution} for details. + error ERC7579InvalidExecution(); + + /// @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) { + (bool allowed, bytes calldata executionCalldata) = _validateExecution(account, salt, mode, data); + returnData = _execute(account, mode, salt, executionCalldata); // Prioritize errors thrown in _execute + require(allowed, ERC7579InvalidExecution()); + return returnData; + } + + /** + * @dev Check if the caller is authorized to execute operations. + * Derived contracts can implement this with custom authorization logic. + * + * Example extension: + * + * ```solidity + * function _validateExecution( + * address account, + * bytes32 salt, + * bytes32 mode, + * bytes calldata data + * ) internal view override returns (bool valid, bytes calldata executionCalldata) { + * /// ... + * return isAuthorized; // custom logic to check authorization + * } + *``` + */ + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal view virtual returns (bool valid, bytes calldata executionCalldata); + + /** + * @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 00000000..971ba742 --- /dev/null +++ b/contracts/account/modules/ERC7579Multisig.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC7913Utils} from "../../utils/cryptography/ERC7913Utils.sol"; +import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; +import {IERC7579Module} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; + +/** + * @dev Implementation of an {IERC7579Module} 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 {_validateMultisignature} internal function. The signers + * are represented using the ERC-7913 format, which concatenates a verifier address and + * a key: `verifier || key`. + * + * Example implementation: + * + * ```solidity + * function execute( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt, + * bytes calldata signature + * ) public virtual { + * require(_validateMultisignature(account, hash, signature)); + * // ... rest of execute logic + * } + * ``` + * + * Example use case: + * + * 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 IERC7579Module { + using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + using ERC7913Utils for bytes32; + using ERC7913Utils for bytes; + + /// @dev Emitted when signers are added. + event ERC7913SignersAdded(address indexed account, bytes[] signers); + + /// @dev Emitted when signers are removed. + event ERC7913SignersRemoved(address indexed account, bytes[] signers); + + /// @dev Emitted when the threshold is updated. + event ERC7913ThresholdSet(address indexed account, uint256 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 unreachable given the number of `signers`. + error ERC7579MultisigUnreachableThreshold(uint256 signers, uint256 threshold); + + mapping(address account => EnumerableSetExtended.BytesSet) private _signersSetByAccount; + mapping(address account => uint256) 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, uint256 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 && _signers(msg.sender).length() == 0) { + // More than just delay parameter + (bytes[] memory signers_, uint256 threshold_) = abi.decode(initData, (bytes[], uint256)); + _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 {EnumerableSetExtended-clear}. + */ + function onUninstall(bytes calldata /* data */) public virtual { + _signersSetByAccount[msg.sender].clear(); + delete _thresholdByAccount[msg.sender]; + } + + /** + * @dev Returns the set of authorized signers for the specified account. + * + * WARNING: This operation copies the entire signers set to memory, which + * can be expensive or may result in unbounded computation. + */ + function signers(address account) public view virtual returns (bytes[] memory) { + return _signers(account).values(); + } + + /// @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 _signers(account).contains(signer); + } + + /// @dev Returns the set of authorized signers for the specified account. + function _signers(address account) internal view virtual returns (EnumerableSetExtended.BytesSet storage) { + return _signersSetByAccount[account]; + } + + /** + * @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 (uint256) { + 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(uint256 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 _validateMultisignature( + address account, + bytes32 hash, + bytes calldata signature + ) internal view virtual 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 { + uint256 newSignersLength = newSigners.length; + for (uint256 i = 0; i < newSignersLength; i++) { + bytes memory signer = newSigners[i]; + require(signer.length >= 20, ERC7579MultisigInvalidSigner(signer)); + require(_signers(account).add(signer), ERC7579MultisigAlreadyExists(signer)); + } + emit ERC7913SignersAdded(account, newSigners); + } + + /** + * @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 { + uint256 oldSignersLength = oldSigners.length; + for (uint256 i = 0; i < oldSignersLength; i++) { + bytes memory signer = oldSigners[i]; + require(_signers(account).remove(signer), ERC7579MultisigNonexistentSigner(signer)); + } + _validateReachableThreshold(account); + emit ERC7913SignersRemoved(account, oldSigners); + } + + /** + * @dev Sets the signatures `threshold` required to approve a multisignature operation. + * + * Requirements: + * + * * The threshold must be reachable with the current number of signers. See {_validateReachableThreshold} for details. + */ + function _setThreshold(address account, uint256 newThreshold) internal virtual { + _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 = _signers(account).length(); + uint256 currentThreshold = threshold(account); + require(totalSigners >= currentThreshold, ERC7579MultisigUnreachableThreshold(totalSigners, 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) { + uint256 signersLength = signingSigners.length; + for (uint256 i = 0; i < signersLength; 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/ERC7579MultisigConfirmation.sol b/contracts/account/modules/ERC7579MultisigConfirmation.sol new file mode 100644 index 00000000..8152b7a4 --- /dev/null +++ b/contracts/account/modules/ERC7579MultisigConfirmation.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC7913Utils} from "../../utils/cryptography/ERC7913Utils.sol"; +import {ERC7579Multisig} from "./ERC7579Multisig.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +/** + * @dev Extension of {ERC7579Multisig} that requires explicit confirmation signatures + * from new signers when they are being added to the multisig. + * + * This module ensures that only willing participants can be added as signers to a + * multisig by requiring each new signer to provide a valid signature confirming their + * consent to be added. Each signer must sign an EIP-712 message to confirm their addition. + * + * TIP: Use this module to ensure that all guardians in a social recovery or multisig setup have + * explicitly agreed to their roles. + */ +abstract contract ERC7579MultisigConfirmation is ERC7579Multisig, EIP712 { + bytes32 private constant MULTISIG_CONFIRMATION = + keccak256("MultisigConfirmation(address account,address module,uint256 deadline)"); + + /// @dev Error thrown when a `signer`'s confirmation signature is invalid + error ERC7579MultisigInvalidConfirmationSignature(bytes signer); + + /// @dev Error thrown when a confirmation signature has expired + error ERC7579MultisigExpiredConfirmation(uint256 deadline); + + /// @dev Generates a hash that signers must sign to confirm their addition to the multisig of `account`. + function _signableConfirmationHash(address account, uint256 deadline) internal view virtual returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(MULTISIG_CONFIRMATION, account, address(this), deadline))); + } + + /** + * @dev Extends {ERC7579Multisig-_addSigners} _addSigners to require confirmation signatures + * Each entry in newSigners must be ABI-encoded as: + * + * ```solidity + * abi.encode(deadline,signer,signature); // uint256, bytes, bytes + * ``` + * + * * signer: The ERC-7913 signer to add + * * signature: The signature from this signer confirming their addition + * + * The function verifies each signature before adding the signer. If any signature is invalid, + * the function reverts with {ERC7579MultisigInvalidConfirmationSignature}. + */ + function _addSigners(address account, bytes[] memory newSigners) internal virtual override { + uint256 newSignersLength = newSigners.length; + for (uint256 i = 0; i < newSignersLength; i++) { + (uint256 deadline, bytes memory signer, bytes memory signature) = abi.decode( + newSigners[i], + (uint256, bytes, bytes) + ); + require(deadline > block.timestamp, ERC7579MultisigExpiredConfirmation(deadline)); + require( + ERC7913Utils.isValidSignatureNow(signer, _signableConfirmationHash(account, deadline), signature), + ERC7579MultisigInvalidConfirmationSignature(signer) + ); + newSigners[i] = signer; // Replace the ABI-encoded value with the signer + } + super._addSigners(account, newSigners); + } +} diff --git a/contracts/account/modules/ERC7579MultisigWeighted.sol b/contracts/account/modules/ERC7579MultisigWeighted.sol new file mode 100644 index 00000000..fc8ac15d --- /dev/null +++ b/contracts/account/modules/ERC7579MultisigWeighted.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC7579Multisig} from "./ERC7579Multisig.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.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 EnumerableSetExtended for EnumerableSetExtended.BytesSet; + + // Mapping from account => signer => weight + mapping(address account => mapping(bytes signer => uint256)) private _weights; + + // Invariant: sum(weights(account)) >= threshold(account) + mapping(address account => uint256 totalWeight) private _totalWeight; + + /// @dev Emitted when a signer's weight is changed. + event ERC7579MultisigWeightChanged(address indexed account, bytes indexed signer, uint256 weight); + + /// @dev Thrown when a signer's weight is invalid. + error ERC7579MultisigInvalidWeight(bytes signer, uint256 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, uint256 threshold, uint256[] 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 = _signers(msg.sender).length() > 0; + super.onInstall(initData); + if (initData.length > 96 && !installed) { + (bytes[] memory signers, , uint256[] memory weights) = abi.decode(initData, (bytes[], uint256, uint256[])); + _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 = signers(account); + uint256 allSignersLength = allSigners.length; + for (uint256 i = 0; i < allSignersLength; i++) { + delete _weights[account][allSigners[i]]; + } + delete _totalWeight[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 (uint256) { + return isSigner(account, signer) ? _signerWeight(account, signer) : 0; + } + + /// @dev Gets the total weight of all signers for a specific account. + function totalWeight(address account) public view virtual returns (uint256) { + return _totalWeight[account]; // Doesn't need Math.max because it's incremented by the default 1 in `_addSigners` + } + + /** + * @dev Sets weights for signers for the calling account. + * Can only be called by the account itself. + */ + function setSignerWeights(bytes[] memory signers, uint256[] memory weights) public virtual { + _setSignerWeights(msg.sender, signers, weights); + } + + /** + * @dev Gets the weight of the current signer. Returns 1 if not explicitly set. + * This internal function doesn't check if the signer is authorized. + */ + function _signerWeight(address account, bytes memory signer) internal view virtual returns (uint256) { + return Math.max(_weights[account][signer], 1); + } + + /** + * @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, uint256[] memory newWeights) internal virtual { + uint256 signersLength = signers.length; + require(signersLength == newWeights.length, ERC7579MultisigMismatchedLength()); + uint256 oldWeight = _weightSigners(account, signers); + + for (uint256 i = 0; i < signersLength; i++) { + bytes memory signer = signers[i]; + uint256 newWeight = newWeights[i]; + require(isSigner(account, signer), ERC7579MultisigNonexistentSigner(signer)); + require(newWeight > 0, ERC7579MultisigInvalidWeight(signer, newWeight)); + } + + _unsafeSetSignerWeights(account, signers, newWeights); + _totalWeight[account] = totalWeight(account) - oldWeight + _weightSigners(account, signers); + _validateReachableThreshold(account); + } + + /** + * @dev Override to add weight tracking. See {ERC7579Multisig-_addSigners}. + * Each new signer has a default weight of 1. + */ + function _addSigners(address account, bytes[] memory newSigners) internal virtual override { + super._addSigners(account, newSigners); + _totalWeight[account] += newSigners.length; // Default weight of 1 per signer. + } + + /// @dev Override to handle weight tracking during removal. See {ERC7579Multisig-_removeSigners}. + function _removeSigners(address account, bytes[] memory oldSigners) internal virtual override { + uint256 removedWeight = _weightSigners(account, oldSigners); + unchecked { + // Can't overflow. Invariant: sum(weights) >= threshold + _totalWeight[account] -= removedWeight; + } + _unsafeSetSignerWeights(account, oldSigners, new uint256[](oldSigners.length)); + 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 { + uint256 weight = totalWeight(account); + uint256 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) { + uint256 totalSigningWeight = _weightSigners(account, validatingSigners); + return totalSigningWeight >= threshold(account); + } + + /// @dev Calculates the total weight of a set of signers. + function _weightSigners(address account, bytes[] memory signers) internal view virtual returns (uint256) { + uint256 weight = 0; + uint256 signersLength = signers.length; + for (uint256 i = 0; i < signersLength; i++) { + weight += signerWeight(account, signers[i]); + } + return weight; + } + + /** + * @dev Sets the `newWeights` for multiple `signers` without updating the {totalWeight} or + * validating the threshold of `account`. + * + * Requirements: + * + * * The `newWeights` array must be at least as large as the `signers` array. Panics otherwise. + * + * Emits {ERC7579MultisigWeightChanged} for each signer. + */ + function _unsafeSetSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) private { + uint256 signersLength = signers.length; + for (uint256 i = 0; i < signersLength; i++) { + _weights[account][signers[i]] = newWeights[i]; + emit ERC7579MultisigWeightChanged(account, signers[i], newWeights[i]); + } + } +} diff --git a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol new file mode 100644 index 00000000..08678bb2 --- /dev/null +++ b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC7579Executor} from "../../../account/modules/ERC7579Executor.sol"; +import {ERC7579Multisig} from "../../../account/modules/ERC7579Multisig.sol"; +import {ERC7579MultisigWeighted} from "../../../account/modules/ERC7579MultisigWeighted.sol"; +import {ERC7579MultisigConfirmation} from "../../../account/modules/ERC7579MultisigConfirmation.sol"; +import {MODULE_TYPE_EXECUTOR, IERC7579Hook} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; + +abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC7579Multisig { + bytes32 private constant EXECUTE_OPERATION = + keccak256("ExecuteOperation(address account,bytes32 mode,bytes executionCalldata,bytes32 salt)"); + + // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal view override returns (bool valid, bytes calldata executionCalldata) { + uint16 executionCalldataLength = uint16(uint256(bytes32(data[0:2]))); // First 2 bytes are the length + bytes calldata actualExecutionCalldata = data[2:2 + executionCalldataLength]; // Next bytes are the calldata + bytes calldata signature = data[2 + executionCalldataLength:]; // Remaining bytes are the signature + return ( + _validateMultisignature( + account, + _getExecuteTypeHash(account, salt, mode, actualExecutionCalldata), + signature + ), + actualExecutionCalldata + ); + } + + function _getExecuteTypeHash( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, mode, executionCalldata, salt))); + } +} + +abstract contract ERC7579MultisigWeightedExecutorMock is EIP712, ERC7579Executor, ERC7579MultisigWeighted { + bytes32 private constant EXECUTE_OPERATION = + keccak256("ExecuteOperation(address account,bytes32 mode,bytes executionCalldata,bytes32 salt)"); + + // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal view override returns (bool valid, bytes calldata executionCalldata) { + uint16 executionCalldataLength = uint16(uint256(bytes32(data[0:2]))); // First 2 bytes are the length + bytes calldata actualExecutionCalldata = data[2:2 + executionCalldataLength]; // Next bytes are the calldata + bytes calldata signature = data[2 + executionCalldataLength:]; // Remaining bytes are the signature + return ( + _validateMultisignature( + account, + _getExecuteTypeHash(account, salt, mode, actualExecutionCalldata), + signature + ), + actualExecutionCalldata + ); + } + + function _getExecuteTypeHash( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, mode, executionCalldata, salt))); + } +} + +abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ERC7579MultisigConfirmation { + bytes32 private constant EXECUTE_OPERATION = + keccak256("ExecuteOperation(address account,bytes32 mode,bytes executionCalldata,bytes32 salt)"); + + // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] + function _validateExecution( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata data + ) internal view override returns (bool valid, bytes calldata executionCalldata) { + uint16 executionCalldataLength = uint16(uint256(bytes32(data[0:2]))); // First 2 bytes are the length + bytes calldata actualExecutionCalldata = data[2:2 + executionCalldataLength]; // Next bytes are the calldata + bytes calldata signature = data[2 + executionCalldataLength:]; // Remaining bytes are the signature + return ( + _validateMultisignature( + account, + _getExecuteTypeHash(account, salt, mode, actualExecutionCalldata), + signature + ), + actualExecutionCalldata + ); + } + + function _getExecuteTypeHash( + address account, + bytes32 salt, + bytes32 mode, + bytes calldata executionCalldata + ) internal view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, mode, executionCalldata, salt))); + } +} diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js new file mode 100644 index 00000000..5be26ebd --- /dev/null +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -0,0 +1,311 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture, time } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { OperationState } = require('../../helpers/enums'); + +const { + MODULE_TYPE_EXECUTOR, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, + encodeMode, + encodeSingle, +} = require('@openzeppelin/contracts/test/helpers/erc7579'); +const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 validator module + const mock = await ethers.deployContract('$ERC7579DelayedExecutor'); + const target = await ethers.deployContract('CallReceiverMockExtended'); + + // 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(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( + OperationState.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( + OperationState.Scheduled, + ); + await time.increase(this.delay); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + OperationState.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( + OperationState.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( + OperationState.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( + OperationState.Scheduled, + ); + await time.increase(this.delay); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + OperationState.Ready, + ); + await time.increase(this.expiration); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + OperationState.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( + OperationState.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( + OperationState.Scheduled, + ); + await time.increase(this.delay); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.eq( + OperationState.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( + OperationState.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 ERC7579ExecutorUnauthorizedSchedule if called by other account', async function () { + await expect( + this.mock.schedule(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedSchedule'); + }); + + 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 + }); + + it('reverts with ERC7579ExecutorUnauthorizedCancellation if called by other account', async function () { + await expect( + this.mock.cancel(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedCancellation'); + }); + }); +}); diff --git a/test/account/modules/ERC7579Executor.test.js b/test/account/modules/ERC7579Executor.test.js new file mode 100644 index 00000000..2e6b1caf --- /dev/null +++ b/test/account/modules/ERC7579Executor.test.js @@ -0,0 +1,73 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); + +const { + MODULE_TYPE_EXECUTOR, + encodeSingle, + encodeMode, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, +} = require('@openzeppelin/contracts/test/helpers/erc7579'); +const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); + +async function fixture() { + // Deploy ERC-7579 validator module + const mock = await ethers.deployContract('$ERC7579MultisigExecutorMock', ['MultisigExecutor', '1']); + const target = await ethers.deployContract('CallReceiverMockExtended'); + + // 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(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/ERC7579Multisig.test.js b/test/account/modules/ERC7579Multisig.test.js new file mode 100644 index 00000000..177052b1 --- /dev/null +++ b/test/account/modules/ERC7579Multisig.test.js @@ -0,0 +1,281 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { NonNativeSigner, MultiERC7913SigningKey } = require('../../helpers/signers'); + +const { + MODULE_TYPE_EXECUTOR, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, + encodeMode, + encodeSingle, +} = require('@openzeppelin/contracts/test/helpers/erc7579'); +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('CallReceiverMockExtended'); + + // 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(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); + + await expect(tx) + .to.emit(this.mock, 'ERC7913SignersAdded') + .withArgs( + this.mockAccount.address, + this.signers.map(signer => signer.toLowerCase()), + ) + .to.emit(this.mock, 'ERC7913ThresholdSet') + .withArgs(this.mockAccount.address, this.threshold); + + // Verify signers and threshold + await expect(this.mock.signers(this.mockAccount.address)).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.signers(this.mockAccount.address)).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.signers(this.mockAccount.address)).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.signers(this.mockAccount.address); + + // Add new signers + await expect(this.mockFromAccount.addSigners(newSigners)) + .to.emit(this.mock, 'ERC7913SignersAdded') + .withArgs( + this.mockAccount.address, + newSigners.map(address => address.toLowerCase()), + ); + + // Get signers after adding + const signersAfter = await this.mock.signers(this.mockAccount.address); + + // 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.signers(this.mockAccount.address); + + // Remove signers + await expect(this.mockFromAccount.removeSigners(removedSigners)) + .to.emit(this.mock, 'ERC7913SignersRemoved') + .withArgs(this.mockAccount.address, removedSigners); + + // Get signers after removing + const signersAfter = await this.mock.signers(this.mockAccount.address); + + // 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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.false; + }); + }); +}); diff --git a/test/account/modules/ERC7579MultisigConfirmation.test.js b/test/account/modules/ERC7579MultisigConfirmation.test.js new file mode 100644 index 00000000..75b0e1cb --- /dev/null +++ b/test/account/modules/ERC7579MultisigConfirmation.test.js @@ -0,0 +1,260 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { time } = require('@nomicfoundation/hardhat-network-helpers'); +const { getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); +const { MultisigConfirmation } = require('../../helpers/eip712-types'); + +const { MODULE_TYPE_EXECUTOR } = require('@openzeppelin/contracts/test/helpers/erc7579'); +const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); + +// Prepare signers in advance +const signerToConfirm = ethers.Wallet.createRandom(); + +async function fixture() { + // Deploy ERC-7579 multisig confirmation module + const mock = await ethers.deployContract('$ERC7579MultisigConfirmationExecutorMock', [ + 'ERC7579MultisigConfirmation', + '1', + ]); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + + // Prepare module installation data + const installData = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'uint256'], [[], 0]); // Empty + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + const mockAccountFromEntrypoint = await impersonate(entrypoint.v08.target).then(asEntrypoint => + mockAccount.connect(asEntrypoint), + ); + + const moduleType = MODULE_TYPE_EXECUTOR; + + await mockAccount.deploy(); + await mockAccountFromEntrypoint.installModule(moduleType, mock.target, installData); + + // Get the EIP-712 domain for the mock module + const domain = await getDomain(mock); + + return { + moduleType, + mock, + mockAccount, + mockFromAccount, + domain, + }; +} + +describe('ERC7579MultisigConfirmation', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeERC7579Module(); + + describe('signer confirmation', function () { + it('can add a signer with valid confirmation signature', async function () { + // Create future deadline for signature validity + const deadline = (await time.latest()) + time.duration.days(1); + + // Generate the typed data hash for confirmation + const typedData = { + account: this.mockAccount.address, + module: this.mock.target, + deadline: deadline, + }; + + // Sign the confirmation message with the signer to be added + const signature = await signerToConfirm.signTypedData(this.domain, { MultisigConfirmation }, typedData); + + // Encode the new signer with deadline and signature + const encodedSigner = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, signerToConfirm.address, signature], + ); + + // Add the new signer with confirmation + await expect(this.mockFromAccount.addSigners([encodedSigner])) + .to.emit(this.mock, 'ERC7913SignersAdded') + .withArgs(this.mockAccount.address, [signerToConfirm.address.toLowerCase()]); + + // Verify the signer was added + await expect(this.mock.isSigner(this.mockAccount.address, signerToConfirm.address)).to.eventually.be.true; + }); + + it('rejects adding a signer with expired deadline', async function () { + // Create expired deadline + const deadline = (await time.latest()) - 1; + + // Generate the typed data hash for confirmation + const typedData = { + account: this.mockAccount.address, + module: this.mock.target, + deadline: deadline, + }; + + // Sign the confirmation message with signerToConfirm + const signature = await signerToConfirm.signTypedData(this.domain, { MultisigConfirmation }, typedData); + + // Encode the new signer with expired deadline and signature + const encodedSigner = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, signerToConfirm.address, signature], + ); + + // Should fail due to expired deadline + await expect(this.mockFromAccount.addSigners([encodedSigner])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigExpiredConfirmation') + .withArgs(deadline); + }); + + it('rejects adding a signer with invalid signature', async function () { + // Create future deadline for signature validity + const deadline = (await time.latest()) + time.duration.days(1); + + // Generate typed data for a different account (invalid for our target) + const typedData = { + account: ethers.Wallet.createRandom().address, // Different account + module: this.mock.target, + deadline: deadline, + }; + + // Sign the invalid confirmation message with signerToConfirm + const signature = await signerToConfirm.signTypedData(this.domain, { MultisigConfirmation }, typedData); + + // Encode the new signer with deadline and invalid signature + const encodedSigner = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, signerToConfirm.address, signature], + ); + + // Should fail due to invalid signature + await expect(this.mockFromAccount.addSigners([encodedSigner])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidConfirmationSignature') + .withArgs(signerToConfirm.address.toLowerCase()); + }); + + it('can add multiple signers with valid confirmation signatures', async function () { + // Create future deadline for signature validity + const deadline = (await time.latest()) + time.duration.days(1); + + // Create another signer to add + const anotherSigner = ethers.Wallet.createRandom(); + + // Generate the typed data for both signers + const typedData = { + account: this.mockAccount.address, + module: this.mock.target, + deadline: deadline, + }; + + // Each signer signs their own confirmation + const signature1 = await signerToConfirm.signTypedData(this.domain, { MultisigConfirmation }, typedData); + const signature2 = await anotherSigner.signTypedData(this.domain, { MultisigConfirmation }, typedData); + + // Encode both signers with their respective signatures + const encodedSigner1 = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, signerToConfirm.address, signature1], + ); + + const encodedSigner2 = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, anotherSigner.address, signature2], + ); + + // Add both signers with confirmation + await expect(this.mockFromAccount.addSigners([encodedSigner1, encodedSigner2])) + .to.emit(this.mock, 'ERC7913SignersAdded') + .withArgs(this.mockAccount.address, [ + signerToConfirm.address.toLowerCase(), + anotherSigner.address.toLowerCase(), + ]); + + // Verify both signers were added + await expect(this.mock.isSigner(this.mockAccount.address, signerToConfirm.address)).to.eventually.be.true; + await expect(this.mock.isSigner(this.mockAccount.address, anotherSigner.address)).to.eventually.be.true; + }); + + it('fails to add multiple signers if any signature is invalid', async function () { + // Create future deadline for signature validity + const deadline = (await time.latest()) + time.duration.days(1); + + // Create another signer to add + const anotherSigner = ethers.Wallet.createRandom(); + + // Generate valid typed data + const validTypedData = { + account: this.mockAccount.address, + module: this.mock.target, + deadline: deadline, + }; + + // Generate invalid typed data with different account + const invalidTypedData = { + account: ethers.Wallet.createRandom().address, + module: this.mock.target, + deadline: deadline, + }; + + // Sign messages - one valid, one invalid + const validSignature = await signerToConfirm.signTypedData(this.domain, { MultisigConfirmation }, validTypedData); + const invalidSignature = await anotherSigner.signTypedData( + this.domain, + { MultisigConfirmation }, + invalidTypedData, + ); + + // Encode both signers + const encodedSigner1 = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, signerToConfirm.address, validSignature], + ); + + const encodedSigner2 = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, anotherSigner.address, invalidSignature], + ); + + // Should fail due to invalid signature for signer4 + await expect(this.mockFromAccount.addSigners([encodedSigner1, encodedSigner2])) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidConfirmationSignature') + .withArgs(anotherSigner.address.toLowerCase()); + + // Verify neither signer was added + await expect(this.mock.isSigner(this.mockAccount.address, signerToConfirm.address)).to.eventually.be.false; + await expect(this.mock.isSigner(this.mockAccount.address, anotherSigner.address)).to.eventually.be.false; + }); + + it('still allows removing signers without confirmation', async function () { + // First, add a signer with valid confirmation + const deadline = (await time.latest()) + time.duration.days(1); + const typedData = { + account: this.mockAccount.address, + module: this.mock.target, + deadline: deadline, + }; + const signature = await signerToConfirm.signTypedData(this.domain, { MultisigConfirmation }, typedData); + const encodedSigner = ethers.AbiCoder.defaultAbiCoder().encode( + ['uint256', 'bytes', 'bytes'], + [deadline, signerToConfirm.address, signature], + ); + + await this.mockFromAccount.addSigners([encodedSigner]); + + // Now remove the signer (no confirmation required for removal) + await expect(this.mockFromAccount.removeSigners([signerToConfirm.address])) + .to.emit(this.mock, 'ERC7913SignersRemoved') + .withArgs(this.mockAccount.address, [signerToConfirm.address.toLowerCase()]); + + // Verify signer was removed + await expect(this.mock.isSigner(this.mockAccount.address, signerToConfirm.address)).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 00000000..959e9bc0 --- /dev/null +++ b/test/account/modules/ERC7579MultisigWeighted.test.js @@ -0,0 +1,364 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { NonNativeSigner, MultiERC7913SigningKey } = require('../../helpers/signers'); + +const { + MODULE_TYPE_EXECUTOR, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, + encodeMode, + encodeSingle, +} = require('@openzeppelin/contracts/test/helpers/erc7579'); +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('CallReceiverMockExtended'); + + // 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(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); + + await expect(tx) + .to.emit(this.mock, 'ERC7913SignersAdded') + .withArgs( + this.mockAccount.address, + this.signers.map(signer => signer.toLowerCase()), + ) + .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.signers(this.mockAccount.address)).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.signers(this.mockAccount.address)).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.signers(this.mockAccount.address); + + // Add new signer + await expect(this.mockFromAccount.addSigners(newSigners)) + .to.emit(this.mock, 'ERC7913SignersAdded') + .withArgs( + this.mockAccount.address, + newSigners.map(address => address.toLowerCase()), + ); + + // Get signers after adding + const signersAfter = await this.mock.signers(this.mockAccount.address); + + // 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, 'ERC7913SignersRemoved') + .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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(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.$_validateMultisignature(this.mockAccount.address, messageHash, invalidSignature)).to + .eventually.be.false; + }); + }); +}); diff --git a/test/helpers/eip712-types.js b/test/helpers/eip712-types.js index ddcfa303..6589713b 100644 --- a/test/helpers/eip712-types.js +++ b/test/helpers/eip712-types.js @@ -26,6 +26,11 @@ module.exports = mapValues( validAfter: 'uint48', validUntil: 'uint48', }, + MultisigConfirmation: { + account: 'address', + module: 'address', + deadline: 'uint256', + }, }, formatType, ); diff --git a/test/helpers/enums.js b/test/helpers/enums.js index 37d9e576..808b79e9 100644 --- a/test/helpers/enums.js +++ b/test/helpers/enums.js @@ -11,4 +11,5 @@ module.exports = { 'EmailProof', ), Case: enums.EnumTyped('CHECKSUM', 'LOWERCASE', 'UPPERCASE', 'ANY'), + OperationState: enums.Enum('Unknown', 'Scheduled', 'Ready', 'Expired', 'Executed', 'Canceled'), };