From 4711f42cbac81b99d4b2e301bb132e426fa429a8 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 20 Apr 2025 21:29:00 -0600 Subject: [PATCH 01/90] Add SignerMultiERC7913 and SignerMultiERC7913Weighted --- CHANGELOG.md | 5 + .../mocks/account/AccountMultiERC7913Mock.sol | 33 +++ .../AccountMultiERC7913WeightedMock.sol | 34 +++ .../mocks/docs/account/MyAccountERC7913.sol | 4 +- .../docs/account/MyAccountMultiERC7913.sol | 51 ++++ .../account/MyAccountMultiERC7913Weighted.sol | 56 ++++ contracts/utils/README.adoc | 7 +- .../utils/cryptography/SignerMultiERC7913.sol | 208 +++++++++++++++ .../SignerMultiERC7913Weighted.sol | 124 +++++++++ docs/modules/ROOT/nav.adoc | 1 + docs/modules/ROOT/pages/multisig-account.adoc | 166 ++++++++++++ test/account/Account.behavior.js | 2 +- test/account/AccountMultiERC7913.test.js | 177 ++++++++++++ .../AccountMultiERC7913Weighted.test.js | 251 ++++++++++++++++++ test/account/AccountZKEmail.test.js | 6 +- test/helpers/signers.js | 62 +++++ 16 files changed, 1180 insertions(+), 7 deletions(-) create mode 100644 contracts/mocks/account/AccountMultiERC7913Mock.sol create mode 100644 contracts/mocks/account/AccountMultiERC7913WeightedMock.sol create mode 100644 contracts/mocks/docs/account/MyAccountMultiERC7913.sol create mode 100644 contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol create mode 100644 contracts/utils/cryptography/SignerMultiERC7913.sol create mode 100644 contracts/utils/cryptography/SignerMultiERC7913Weighted.sol create mode 100644 docs/modules/ROOT/pages/multisig-account.adoc create mode 100644 test/account/AccountMultiERC7913.test.js create mode 100644 test/account/AccountMultiERC7913Weighted.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c9b73c..86cbb03a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 20-04-2025 + +- `SignerMultiERC7913`: Implementation of `AbstractSigner` that supports multiple ERC-7913 signers with a threshold-based signature verification system. +- `SignerMultiERC7913Weighted`: Extension of `SignerMultiERC7913` that supports assigning different weights to each signer, enabling more flexible governance schemes. + ## 12-04-2025 - `SignerERC7913`: Abstract signer that verifies signatures using the ERC-7913 workflow. diff --git a/contracts/mocks/account/AccountMultiERC7913Mock.sol b/contracts/mocks/account/AccountMultiERC7913Mock.sol new file mode 100644 index 00000000..b5a1df82 --- /dev/null +++ b/contracts/mocks/account/AccountMultiERC7913Mock.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Account} from "../../account/Account.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC7739} from "../../utils/cryptography/ERC7739.sol"; +import {ERC7821} from "../../account/extensions/ERC7821.sol"; +import {SignerMultiERC7913} from "../../utils/cryptography/SignerMultiERC7913.sol"; + +abstract contract AccountMultiERC7913Mock is + Account, + SignerMultiERC7913, + ERC7739, + ERC7821, + ERC721Holder, + ERC1155Holder +{ + constructor(bytes[] memory signers, uint256 threshold) { + _addSigners(signers); + _setThreshold(threshold); + } + + /// @inheritdoc ERC7821 + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} diff --git a/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol b/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol new file mode 100644 index 00000000..3520a7d6 --- /dev/null +++ b/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Account} from "../../account/Account.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC7739} from "../../utils/cryptography/ERC7739.sol"; +import {ERC7821} from "../../account/extensions/ERC7821.sol"; +import {SignerMultiERC7913Weighted} from "../../utils/cryptography/SignerMultiERC7913Weighted.sol"; + +abstract contract AccountMultiERC7913WeightedMock is + Account, + SignerMultiERC7913Weighted, + ERC7739, + ERC7821, + ERC721Holder, + ERC1155Holder +{ + constructor(bytes[] memory signers, uint256[] memory weights, uint256 threshold) { + _addSigners(signers); + _setSignerWeights(signers, weights); + _setThreshold(threshold); + } + + /// @inheritdoc ERC7821 + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} diff --git a/contracts/mocks/docs/account/MyAccountERC7913.sol b/contracts/mocks/docs/account/MyAccountERC7913.sol index 165d77a2..b6a54586 100644 --- a/contracts/mocks/docs/account/MyAccountERC7913.sol +++ b/contracts/mocks/docs/account/MyAccountERC7913.sol @@ -1,4 +1,4 @@ -// contracts/MyAccount.sol +// contracts/MyAccountERC7913.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; @@ -12,7 +12,7 @@ import {ERC7821} from "../../../account/extensions/ERC7821.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {SignerERC7913} from "../../../utils/cryptography/SignerERC7913.sol"; -contract MyAccount7913 is Account, SignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder, Initializable { +contract MyAccountERC7913 is Account, SignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder, Initializable { constructor() EIP712("MyAccount7913", "1") {} function initialize(bytes memory signer) public initializer { diff --git a/contracts/mocks/docs/account/MyAccountMultiERC7913.sol b/contracts/mocks/docs/account/MyAccountMultiERC7913.sol new file mode 100644 index 00000000..df6b8446 --- /dev/null +++ b/contracts/mocks/docs/account/MyAccountMultiERC7913.sol @@ -0,0 +1,51 @@ +// contracts/MyAccountERC7913.sol +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Account} from "../../../account/Account.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC7739} from "../../../utils/cryptography/ERC7739.sol"; +import {ERC7821} from "../../../account/extensions/ERC7821.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {SignerMultiERC7913} from "../../../utils/cryptography/SignerMultiERC7913.sol"; + +contract MyAccountMultiERC7913 is + Account, + SignerMultiERC7913, + ERC7739, + ERC7821, + ERC721Holder, + ERC1155Holder, + Initializable +{ + constructor() EIP712("MyAccountMultiERC7913", "1") {} + + function initialize(bytes[] memory signers, uint256 threshold) public initializer { + _addSigners(signers); + _setThreshold(threshold); + } + + function addSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + _addSigners(signers); + } + + function removeSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + _removeSigners(signers); + } + + function setThreshold(uint256 threshold) public onlyEntryPointOrSelf { + _setThreshold(threshold); + } + + /// @dev Allows the entry point as an authorized executor. + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} diff --git a/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol b/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol new file mode 100644 index 00000000..6a7f4216 --- /dev/null +++ b/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol @@ -0,0 +1,56 @@ +// contracts/MyAccountERC7913.sol +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Account} from "../../../account/Account.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC7739} from "../../../utils/cryptography/ERC7739.sol"; +import {ERC7821} from "../../../account/extensions/ERC7821.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {SignerMultiERC7913Weighted} from "../../../utils/cryptography/SignerMultiERC7913Weighted.sol"; + +contract MyAccountMultiERC7913Weighted is + Account, + SignerMultiERC7913Weighted, + ERC7739, + ERC7821, + ERC721Holder, + ERC1155Holder, + Initializable +{ + constructor() EIP712("MyAccountMultiERC7913Weighted", "1") {} + + function initialize(bytes[] memory signers, uint256[] memory weights, uint256 threshold) public initializer { + _addSigners(signers); + _setSignerWeights(signers, weights); + _setThreshold(threshold); + } + + function addSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + _addSigners(signers); + } + + function removeSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + _removeSigners(signers); + } + + function setThreshold(uint256 threshold) public onlyEntryPointOrSelf { + _setThreshold(threshold); + } + + function setSignerWeights(bytes[] memory signers, uint256[] memory weights) public onlyEntryPointOrSelf { + _setSignerWeights(signers, weights); + } + + /// @dev Allows the entry point as an authorized executor. + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index c68dd768..25821dc5 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -9,7 +9,8 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {ERC7739}: An abstract contract to validate signatures following the rehashing scheme from `ERC7739Utils`. * {ERC7739Utils}: Utilities library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on ERC-7739. * {ERC7913Utils}: utilities library that implements ERC-7913 signature verification with fallback to ERC-1271 and ECDSA. - * {SignerECDSA}, {SignerERC7913}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. + * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. + * {SignerERC7913}, {SignerMultiERC7913}, {SignerMultiERC7913Weighted}: Implementations of {AbstractSigner} that validate signatures based on ERC-7913. Including a simple and weighted multisignature scheme. * {ERC7913SignatureVerifierP256}, {ERC7913SignatureVerifierRSA}: Ready to use ERC-7913 signature verifiers for P256 and RSA keys * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. * {SignerZKEmail}: Implementation of an {AbstractSigner} that enables email-based authentication through zero-knowledge proofs. @@ -33,6 +34,10 @@ Miscellaneous contracts and libraries containing utility functions you can use t {{SignerERC7913}} +{{SignerMultiERC7913}} + +{{SignerMultiERC7913Weighted}} + {{SignerP256}} {{SignerERC7702}} diff --git a/contracts/utils/cryptography/SignerMultiERC7913.sol b/contracts/utils/cryptography/SignerMultiERC7913.sol new file mode 100644 index 00000000..8ceb69a1 --- /dev/null +++ b/contracts/utils/cryptography/SignerMultiERC7913.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {AbstractSigner} from "./AbstractSigner.sol"; +import {ERC7913Utils} from "./ERC7913Utils.sol"; +import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; +import {Calldata} from "@openzeppelin/contracts/utils/Calldata.sol"; + +/** + * @dev Implementation of {AbstractSigner} using multiple ERC-7913 signers with a threshold-based + * signature verification system. + * + * This contract allows managing a set of authorized signers and requires a minimum number of + * signatures (threshold) to approve operations. It uses ERC-7913 formatted signers, which + * concatenate a verifier address and a key: `verifier || key`. + * + * Example of usage: + * + * ```solidity + * contract MyMultiSignerAccount is Account, SignerMultiERC7913, Initializable { + * constructor() EIP712("MyMultiSignerAccount", "1") {} + * + * function initialize(bytes[] memory signers, uint256 threshold) public initializer { + * _addSigners(signers); + * _setThreshold(threshold); + * } + * + * function addSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + * _addSigners(signers); + * } + * + * function removeSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + * _removeSigners(signers); + * } + * + * function setThreshold(uint256 threshold) public onlyEntryPointOrSelf { + * _setThreshold(threshold); + * } + * } + * ``` + * + * IMPORTANT: Failing to properly initialize the signers and threshold either during construction + * (if used standalone) or during initialization (if used as a clone) may leave the contract + * either front-runnable or unusable. + */ +abstract contract SignerMultiERC7913 is AbstractSigner { + using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + using ERC7913Utils for bytes; + + /// @dev Emitted when signers are added. + event ERC7913SignersAdded(bytes[] indexed signers); + + /// @dev Emitted when signers are removed. + event ERC7913SignersRemoved(bytes[] indexed signers); + + /// @dev Emitted when the threshold is updated. + event ThresholdSet(uint256 threshold); + + /// @dev The `signer` already exists. + error MultiERC7913SignerAlreadyExists(bytes signer); + + /// @dev The `signer` does not exist. + error MultiERC7913SignerNonexistentSigner(bytes signer); + + /// @dev The `signer` is less than 20 bytes long. + error MultiERC7913InvalidSigner(bytes signer); + + /// @dev The `threshold` is unreachable given the number of `signers`. + error MultiERC7913UnreachableThreshold(uint256 signers, uint256 threshold); + + EnumerableSetExtended.BytesSet private _signersSet; + uint256 private _minSigners; + + /// @dev Returns the internal id of the `signer`. + function signerId(bytes memory signer) public view virtual returns (bytes32) { + return keccak256(signer); + } + + /// @dev Returns the set of authorized signers. + function _signers() internal view virtual returns (EnumerableSetExtended.BytesSet storage) { + return _signersSet; + } + + /// @dev Returns the minimum number of signers required to approve a multisignature operation. + function _threshold() internal view virtual returns (uint256) { + return _minSigners; + } + + /// @dev Adds the `signers` to those allowed to sign on behalf of this contract. Internal version without access control. + function _addSigners(bytes[] memory signers) internal virtual { + for (uint256 i = 0; i < signers.length; i++) { + bytes memory signer = signers[i]; + require(signer.length >= 20, MultiERC7913InvalidSigner(signer)); + require(_signersSet.add(signer), MultiERC7913SignerAlreadyExists(signer)); + } + emit ERC7913SignersAdded(signers); + } + + /// @dev Removes the `signers` from the authorized signers. Internal version without access control. + function _removeSigners(bytes[] memory signers) internal virtual { + for (uint256 i = 0; i < signers.length; i++) { + bytes memory signer = signers[i]; + require(_signersSet.remove(signer), MultiERC7913SignerNonexistentSigner(signer)); + } + _validateReachableThreshold(); + emit ERC7913SignersRemoved(signers); + } + + /// @dev Sets the signatures `threshold` required to approve a multisignature operation. Internal version without access control. + function _setThreshold(uint256 threshold_) internal virtual { + _minSigners = threshold_; + _validateReachableThreshold(); + emit ThresholdSet(threshold_); + } + + /// @dev Validates the current threshold is reachable. + function _validateReachableThreshold() internal view virtual { + uint256 signers = _signers().length(); + uint256 threshold = _threshold(); + require(signers >= threshold, MultiERC7913UnreachableThreshold(signers, threshold)); + } + + /** + * @dev Decodes, validates the signature and checks the signers are authorized. + * See {_validateNSignatures} and {_validateThreshold} for more details. + * + * Example of signature encoding: + * + * ```solidity + * // Encode signers (verifier || key) + * bytes memory signer1 = abi.encodePacked(verifier1, key1); + * bytes memory signer2 = abi.encodePacked(verifier2, key2); + * + * // Order signers by their id + * if (keccak256(signer1) > keccak256(signer2)) { + * (signer1, signer2) = (signer2, signer1); + * (signature1, signature2) = (signature2, signature1); + * } + * + * // Assign ordered signers and signatures + * bytes[] memory signers = new bytes[](2); + * bytes[] memory signatures = new bytes[](2); + * signers[0] = signer1; + * signatures[0] = signature1; + * signers[1] = signer2; + * signatures[1] = signature2; + * + * // Encode the multi signature + * bytes memory signature = abi.encode(signers, signatures); + * ``` + */ + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + if (signature.length == 0) return false; // For ERC-7739 compatibility + (bytes[] memory signers, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); + if (signers.length != signatures.length) return false; + return _validateNSignatures(hash, signers, signatures) && _validateThreshold(signers); + } + + /** + * @dev Validates the signatures using the signers and their corresponding signatures. + * Returns whether whether the signers are authorized and the signatures are valid for the given hash. + * + * IMPORTANT: For simplicity, this contract assumes that the signers are ordered by their {signerId} to + * avoid duplication when iterating through the signers (i.e. `signerId(signer1) < signerId(signer2)`). + * The function will return false if the signers are not ordered. + * + * Requirements: + * + * - The `signers` and `signatures` arrays must be of the same length. + */ + function _validateNSignatures( + bytes32 hash, + bytes[] memory signers, + bytes[] memory signatures + ) internal view virtual returns (bool valid) { + bytes32 currentSignerId = bytes32(0); + + uint256 signersLength = signers.length; + for (uint256 i = 0; i < signersLength; i++) { + // Signers must ordered by id to ensure no duplicates + bytes memory signer = signers[i]; + bytes32 id = signerId(signer); + if ( + currentSignerId >= id || + !_signers().contains(signer) || + !signer.isValidSignatureNow(hash, signatures[i]) + ) { + return false; + } + + currentSignerId = id; + } + + return true; + } + + /** + * @dev Validates that the number of signers meets the {_threshold} requirement. + * Assumes the signers were already validated. See {_validateNSignatures} for more details. + */ + function _validateThreshold(bytes[] memory validatedSigners) internal view virtual returns (bool) { + return validatedSigners.length >= _threshold(); + } +} diff --git a/contracts/utils/cryptography/SignerMultiERC7913Weighted.sol b/contracts/utils/cryptography/SignerMultiERC7913Weighted.sol new file mode 100644 index 00000000..16ffec9a --- /dev/null +++ b/contracts/utils/cryptography/SignerMultiERC7913Weighted.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SignerMultiERC7913} from "./SignerMultiERC7913.sol"; +import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; + +/** + * @dev Extension of {SignerMultiERC7913} that supports weighted signatures. + * + * This contract allows assigning different weights to each signer, enabling more + * flexible governance schemes. For example, some signers could have higher weight + * than others, allowing for weighted voting or prioritized authorization. + * + * Example of usage: + * + * ```solidity + * contract MyWeightedMultiSignerAccount is Account, SignerMultiERC7913Weighted, Initializable { + * constructor() EIP712("MyWeightedMultiSignerAccount", "1") {} + * + * function initialize(bytes[] memory signers, uint256[] memory weights, uint256 threshold) public initializer { + * _addSigners(signers); + * _setSignerWeights(signers, weights); + * _setThreshold(threshold); + * } + * + * function addSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + * _addSigners(signers); + * } + * + * function removeSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + * _removeSigners(signers); + * } + * + * function setThreshold(uint256 threshold) public onlyEntryPointOrSelf { + * _setThreshold(threshold); + * } + * + * function setSignerWeights(bytes[] memory signers, uint256[] memory weights) public onlyEntryPointOrSelf { + * _setSignerWeights(signers, weights); + * } + * } + * ``` + * + * 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 at + * least two signers (e.g., one with weight 1 and one with weight 3). See {signerWeight}. + */ +abstract contract SignerMultiERC7913Weighted is SignerMultiERC7913 { + using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + + // Mapping from signer ID to weight + mapping(bytes32 signedId => uint256) private _weights; + + /// @dev Emitted when a signer's weight is changed. + event ERC7913SignerWeightChanged(bytes indexed signer, uint256 weight); + + /// @dev Emitted when a signer's weight is invalid. + error MultiERC7913WeightedInvalidWeight(bytes signer, uint256 weight); + + error MultiERC7913WeightedMismatchedLength(); + + /// @dev Gets the weight of a signer. Returns 1 if not explicitly set. + function signerWeight(bytes memory signer) public view virtual returns (uint256) { + return Math.max(_weights[signerId(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 {MultiERC7913WeightedMismatchedLength} on mismatch. + * - Each signer must exist in the set of authorized signers. Reverts with {MultiERC7913SignerNonexistentSigner} if not. + * - Each weight must be greater than 0. Reverts with {MultiERC7913WeightedInvalidWeight} if not. + */ + function _setSignerWeights(bytes[] memory signers, uint256[] memory weights) internal virtual { + require(signers.length == weights.length, MultiERC7913WeightedMismatchedLength()); + + for (uint256 i = 0; i < signers.length; i++) { + bytes memory signer = signers[i]; + uint256 weight = weights[i]; + require(_signers().contains(signer), MultiERC7913SignerNonexistentSigner(signer)); + require(weight > 0, MultiERC7913WeightedInvalidWeight(signer, weight)); + + _weights[signerId(signer)] = weight; + emit ERC7913SignerWeightChanged(signer, weight); + } + + _validateReachableThreshold(); + } + + /// @dev Sets the threshold for the multisignature operation. Internal version without access control. + function _validateReachableThreshold() internal view virtual override { + // This override intentionally does not call `super._validateReachableThreshold` since that would + // perform a comparison of `signers.length >= _threshold`, which is the less-weight per signer + // scenario. This would cause a duplicated and unnecessary SLOAD. Since `_validateReachableThreshold` is + // a `view` function, there are no state changes that we would miss by not calling super. + require( + _weightSigners(_signers().values()) >= _threshold(), // TODO: Should there be a max signers? + MultiERC7913UnreachableThreshold(_signers().length(), _threshold()) + ); + } + + /// @dev Overrides the threshold validation to use signer weights. + function _validateThreshold(bytes[] memory signers) internal view virtual override returns (bool) { + // This override intentionally does not call `super._validateThreshold` since that would + // perform a comparison of `signers.length >= _threshold`, which is the less-weight per signer + // scenario. This would cause a duplicated and unnecessary SLOAD. Since `_validateThreshold` is + // a `view` function, there are no state changes that we would miss by not calling super. + return _weightSigners(signers) >= _threshold(); /* || super._validateThreshold(signers) */ + } + + /// @dev Calculates the total weight of a set of signers. + function _weightSigners(bytes[] memory signers) internal view virtual returns (uint256) { + uint256 totalWeight = 0; + uint256 signersLength = signers.length; + for (uint256 i = 0; i < signersLength; i++) { + totalWeight += signerWeight(signers[i]); + } + return totalWeight; + } +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index f46339c1..c5b408c9 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -1,3 +1,4 @@ * xref:index.adoc[Overview] * xref:account-abstraction.adoc[Account Abstraction] +** xref:multisig-account.adoc[Multisig Account] * xref:utilities.adoc[Utilities] diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc new file mode 100644 index 00000000..48eaec80 --- /dev/null +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -0,0 +1,166 @@ += Multisig Account + +A multi-signature (multisig) account is a smart account that requires multiple authorized signers to approve operations before execution. Unlike traditional accounts controlled by a single private key, multisigs distribute control among multiple parties, eliminating single points of failure. For example, a 2-of-3 multisig requires signatures from at least 2 out of 3 possible signers. + +Popular implementations like https://safe.global/[Safe] (formerly Gnosis Safe) have become the standard for securing valuable assets. Multisigs provide enhanced security through collective authorization, customizable controls for ownership and thresholds, and the ability to rotate signers without changing the account address. + +== Beyond Standard Signature Verification + +The standard approach for smart contracts to verify signatures is through https://eips.ethereum.org/EIPS/eip-1271[ERC-1271], which defines an `isValidSignature(hash, signature)` function for contracts to implement. However, ERC-1271 is limited in two important ways: + +1. It assumes the signer has an EVM address +2. It treats the signer as a single identity + +This becomes problematic when implementing multisig accounts where: + +* You may want to use signers that don't have EVM addresses (like keys from hardware devices) +* Each signer needs to be individually verified rather than treated as a collective identity +* You need a threshold system to determine when enough valid signatures are present + +The https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/SignatureChecker.sol[SignatureChecker] library is useful for verifying EOA and ERC-1271 signatures, but it's not designed for more complex arrangements like threshold-based multisigs. + +== ERC-7913 Signers + +https://eips.ethereum.org/EIPS/eip-7913[ERC-7913] addresses these limitations by extending the concept of signer representation to include keys that don't have EVM addresses. OpenZeppelin implements this standard through three contracts: + +=== SignerERC7913 + +The xref:api:utils.adoc#SignerERC7913[`SignerERC7913`] contract allows a single ERC-7913 formatted signer to control an account. The signer is represented as a `bytes` object that concatenates a verifier address and a key: `verifier || key`. + +[source,solidity] +---- +include::api:example$account/MyAccountERC7913.sol[] +---- + +WARNING: Leaving an account uninitialized may leave it unusable since no public key was associated with it. + +=== SignerMultiERC7913 + +The xref:api:utils.adoc#SignerMultiERC7913[`SignerMultiERC7913`] contract extends this concept to support multiple signers with a threshold-based signature verification system. + +[source,solidity] +---- +include::api:example$account/MyAccountMultiERC7913.sol[] +---- +This implementation is ideal for standard multisig setups where each signer has equal authority, and a fixed number of approvals is required. + +The `SignerMultiERC7913` contract provides several key features for managing multi-signature accounts. It maintains a set of authorized signers and implements a threshold-based system that requires a minimum number of signatures to approve operations. The contract includes an internal interface for managing signers, allowing for the addition and removal of authorized parties. + +NOTE: `SignerMultiERC7913` safeguards to ensure that the threshold remains achievable based on the current number of active signers, preventing situations where operations could become impossible to execute. + +=== SignerMultiERC7913Weighted + +For more sophisticated governance structures, the xref:api:utils.adoc#SignerMultiERC7913Weighted[`SignerMultiERC7913Weighted`] contract extends `SignerMultiERC7913` by assigning different weights to each signer. + +[source,solidity] +---- +include::api:example$account/MyAccountMultiERC7913Weighted.sol[] +---- + +This implementation is perfect for scenarios where different signers should have varying levels of authority, such as: + +* Board members with different voting powers +* Organizational structures with hierarchical decision-making +* Hybrid governance systems combining core team and community members + +The `SignerMultiERC7913Weighted` contract extends `SignerMultiERC7913` with a weighting system. Each signer can have a custom weight, and operations require the total weight of signing participants to meet or exceed the threshold. Signers without explicit weights default to a weight of 1. + +NOTE: When setting up a weighted multisig, ensure the threshold value 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 at least two signers (e.g., one with weight 1 and one with weight 3). + +== Setting Up a Multisig Account + +To create a multisig account, you need to: + +1. Define your signers +2. Determine your threshold +3. Initialize your account with these parameters + +The example below demonstrates setting up a 2-of-3 multisig account with different types of signers: + +[source,solidity] +---- +// Example setup code +function setupMultisigAccount() external { + // Create signers using different types of keys + bytes memory ecdsaSigner = alice; // EOA address (20 bytes) + + // P256 signer with format: verifier || pubKey + bytes memory p256Signer = abi.encodePacked( + p256Verifier, + bobP256PublicKeyX, + bobP256PublicKeyY + ); + + // RSA signer with format: verifier || pubKey + bytes memory rsaSigner = abi.encodePacked( + rsaVerifier, + abi.encode(charlieRSAPublicKeyE, charlieRSAPublicKeyN) + ); + + // Create array of signers + bytes[] memory signers = new bytes[](3); + signers[0] = ecdsaSigner; + signers[1] = p256Signer; + signers[2] = rsaSigner; + + // Set threshold to 2 (2-of-3 multisig) + uint256 threshold = 2; + + // Initialize the account + myMultisigAccount.initialize(signers, threshold); +} +---- + +For a weighted multisig, you would also specify weights: + +[source,solidity] +---- +// Example setup for weighted multisig +function setupWeightedMultisigAccount() external { + // Create array of signers (same as above) + bytes[] memory signers = new bytes[](3); + signers[0] = ecdsaSigner; + signers[1] = p256Signer; + signers[2] = rsaSigner; + + // Assign weights to signers (Alice:1, Bob:2, Charlie:3) + uint256[] memory weights = new uint256[](3); + weights[0] = 1; + weights[1] = 2; + weights[2] = 3; + + // Set threshold to 4 (requires at least Bob+Charlie or all three) + uint256 threshold = 4; + + // Initialize the weighted account + myWeightedMultisigAccount.initialize(signers, weights, threshold); +} +---- + +IMPORTANT: Always ensure that the sum of weights for all active signers meets or exceeds the threshold. Otherwise, it would be impossible to reach the required threshold for approving operations. + +== How to Encode a Signature + +For multisig accounts, the signature is a complex structure that contains both the signers and their individual signatures. The format follows ERC-7913's specification and must be properly encoded. + +=== Signature Format + +The multisig signature is encoded as: + +[source,solidity] +---- +abi.encode( + bytes[] signers, // Array of signers in order + bytes[] signatures // Array of signatures corresponding to each signer +) +---- + +Where: + +* `signers` is an array of the signers participating in this particular signature +* `signatures` is an array of the individual signatures corresponding to each signer + +[NOTE] +==== +For simplicity, `signers` should match in order with `signatures`. And also, to avoid duplicate usage of a single signature, the contract provides an xref:api:utils.adoc#SignerMultiERC7913-signerId-bytes-[`signerId`] function that exposes a single id for each signer. When providing a signature, the `signers` array should be in ascending order by `signerId`. +==== diff --git a/test/account/Account.behavior.js b/test/account/Account.behavior.js index 22e41dd7..b52c26c0 100644 --- a/test/account/Account.behavior.js +++ b/test/account/Account.behavior.js @@ -47,7 +47,7 @@ function shouldBehaveLikeAccountCore() { it('should return SIG_VALIDATION_FAILURE if the signature is invalid', async function () { // empty operation (does nothing) const operation = await this.mock.createUserOp(this.userOp); - operation.signature = '0x00'; + operation.signature = (await this.invalidSig?.()) ?? '0x00'; expect(await this.mockFromEntrypoint.validateUserOp.staticCall(operation.packed, operation.hash(), 0)).to.eq( SIG_VALIDATION_FAILURE, diff --git a/test/account/AccountMultiERC7913.test.js b/test/account/AccountMultiERC7913.test.js new file mode 100644 index 00000000..a7578ecb --- /dev/null +++ b/test/account/AccountMultiERC7913.test.js @@ -0,0 +1,177 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, MultiERC7913SigningKey } = require('../helpers/signers'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); +const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); +const { PackedUserOperation } = require('../helpers/eip712-types'); + +// Prepare signers in advance (RSA are long to initialize) +const signerECDSA1 = ethers.Wallet.createRandom(); +const signerECDSA2 = ethers.Wallet.createRandom(); +const signerECDSA3 = ethers.Wallet.createRandom(); +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + +// Minimal fixture common to the different signer verifiers +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMockExtended'); + + // ERC-7913 verifiers + const verifierP256 = await ethers.deployContract('ERC7913SignatureVerifierP256'); + const verifierRSA = await ethers.deployContract('ERC7913SignatureVerifierRSA'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(entrypoint.v08); + const domain = { name: 'AccountMultiERC7913', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract + + const makeMock = (signers, threshold) => + helper.newAccount('$AccountMultiERC7913Mock', ['AccountMultiERC7913', '1', signers, threshold]).then(mock => { + domain.verifyingContract = mock.address; + return mock; + }); + + // Sign user operations using MultiERC7913SigningKey + const signUserOp = function (userOp) { + return this.signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + }; + + const invalidSig = function () { + return this.signer.signMessage('invalid'); + }; + + return { + helper, + verifierP256, + verifierRSA, + domain, + target, + beneficiary, + other, + makeMock, + signUserOp, + invalidSig, + }; +} + +describe('AccountMultiERC7913', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('Multi ECDSA signers with threshold=1', function () { + beforeEach(async function () { + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1])); + this.mock = await this.makeMock([signerECDSA1.address], 1); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Multi ECDSA signers with threshold=2', function () { + beforeEach(async function () { + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 2); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Mixed signers with threshold=2', function () { + beforeEach(async function () { + // Create signers array with all three types + signerP256.bytes = ethers.concat([ + this.verifierP256.target, + signerP256.signingKey.publicKey.qx, + signerP256.signingKey.publicKey.qy, + ]); + + signerRSA.bytes = ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [signerRSA.signingKey.publicKey.e, signerRSA.signingKey.publicKey.n], + ), + ]); + + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerP256, signerRSA])); + this.mock = await this.makeMock([signerECDSA1.address, signerP256.bytes, signerRSA.bytes], 2); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Signer management', function () { + const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); + + beforeEach(async function () { + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); + this.mock = await this.makeMock( + [encodeECDSASigner(signerECDSA1.address), encodeECDSASigner(signerECDSA2.address)], + 1, + ); + await this.mock.deploy(); + }); + + it('can add signers', async function () { + const signers = [ + encodeECDSASigner(signerECDSA3.address), // ECDSA Signer + ]; + + // Successfully adds a signer + await expect(this.mock.$_addSigners(signers)) + .to.emit(this.mock, 'ERC7913SignersAdded') + .withArgs(...signers); + + // Reverts if the signer was already added + await expect(this.mock.$_addSigners(signers)) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913SignerAlreadyExists') + .withArgs(...signers); + }); + + it('can remove signers', async function () { + const signers = [encodeECDSASigner(signerECDSA2.address)]; + + // Successfully removes an already added signer + await expect(this.mock.$_removeSigners(signers)) + .to.emit(this.mock, 'ERC7913SignersRemoved') + .withArgs(...signers); + + // Reverts removing a signer if it doesn't exist + await expect(this.mock.$_removeSigners(signers)) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913SignerNonexistentSigner') + .withArgs(...signers); + }); + + it('can change threshold', async function () { + // Reachable threshold is set + await expect(this.mock.$_setThreshold(2)).to.emit(this.mock, 'ThresholdSet'); + + // Unreachable threshold reverts + await expect(this.mock.$_setThreshold(3)).to.revertedWithCustomError( + this.mock, + 'MultiERC7913UnreachableThreshold', + ); + }); + }); +}); diff --git a/test/account/AccountMultiERC7913Weighted.test.js b/test/account/AccountMultiERC7913Weighted.test.js new file mode 100644 index 00000000..5277e44a --- /dev/null +++ b/test/account/AccountMultiERC7913Weighted.test.js @@ -0,0 +1,251 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, MultiERC7913SigningKey } = require('../helpers/signers'); +const { PackedUserOperation } = require('../helpers/eip712-types'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); +const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); + +// Prepare signers in advance (RSA are long to initialize) +const signerECDSA1 = ethers.Wallet.createRandom(); +const signerECDSA2 = ethers.Wallet.createRandom(); +const signerECDSA3 = ethers.Wallet.createRandom(); +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + +// Minimal fixture common to the different signer verifiers +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMockExtended'); + + // ERC-7913 verifiers + const verifierP256 = await ethers.deployContract('ERC7913SignatureVerifierP256'); + const verifierRSA = await ethers.deployContract('ERC7913SignatureVerifierRSA'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(entrypoint.v08); + const domain = { name: 'AccountMultiERC7913Weighted', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract + + const makeMock = (signers, weights, threshold) => + helper + .newAccount('$AccountMultiERC7913WeightedMock', ['AccountMultiERC7913Weighted', '1', signers, weights, threshold]) + .then(mock => { + domain.verifyingContract = mock.address; + return mock; + }); + + // Sign user operations using NonNativeSigner with MultiERC7913SigningKey + const signUserOp = function (userOp) { + return this.signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + }; + + const invalidSig = function () { + return this.signer.signMessage('invalid'); + }; + + return { + helper, + verifierP256, + verifierRSA, + domain, + target, + beneficiary, + other, + makeMock, + signUserOp, + invalidSig, + }; +} + +describe('AccountMultiERC7913Weighted', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('Weighted signers with equal weights (1, 1, 1) and threshold=2', function () { + beforeEach(async function () { + const weights = [1, 1, 1]; + this.signer = new NonNativeSigner( + new MultiERC7913SigningKey([signerECDSA1, signerECDSA2, signerECDSA3], weights), + ); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address, signerECDSA3.address], weights, 2); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Weighted signers with varying weights (1, 2, 3) and threshold=3', function () { + beforeEach(async function () { + const weights = [1, 2, 3]; + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2], weights.slice(1))); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address, signerECDSA3.address], weights, 3); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Mixed weighted signers with threshold=4', function () { + beforeEach(async function () { + // Create signers array with all three types + signerP256.bytes = ethers.concat([ + this.verifierP256.target, + signerP256.signingKey.publicKey.qx, + signerP256.signingKey.publicKey.qy, + ]); + + signerRSA.bytes = ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [signerRSA.signingKey.publicKey.e, signerRSA.signingKey.publicKey.n], + ), + ]); + + const weights = [1, 2, 3]; + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerP256, signerRSA], weights)); + this.mock = await this.makeMock( + [signerECDSA1.address, signerP256.bytes, signerRSA.bytes], + weights, + 4, // Requires at least signer2 + signer3, or all three signers + ); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe.skip('Weight management', function () { + const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); + + beforeEach(async function () { + const weights = [1, 2, 3]; + this.signer = new NonNativeSigner( + new MultiERC7913SigningKey([signerECDSA1, signerECDSA2, signerECDSA3], weights), + ); + this.mock = await this.makeMock( + [ + encodeECDSASigner(signerECDSA1.address), + encodeECDSASigner(signerECDSA2.address), + encodeECDSASigner(signerECDSA3.address), + ], + weights, + 4, + ); + await this.mock.deploy(); + }); + + it('can get signer weights', async function () { + const signer1 = encodeECDSASigner(signerECDSA1.address); + const signer2 = encodeECDSASigner(signerECDSA2.address); + const signer3 = encodeECDSASigner(signerECDSA3.address); + + const weight1 = await this.mock.signerWeight(signer1); + const weight2 = await this.mock.signerWeight(signer2); + const weight3 = await this.mock.signerWeight(signer3); + + expect(weight1).to.equal(1); + expect(weight2).to.equal(2); + expect(weight3).to.equal(3); + }); + + it('can update signer weights', async function () { + const signer1 = encodeECDSASigner(signerECDSA1.address); + const signer2 = encodeECDSASigner(signerECDSA2.address); + const signer3 = encodeECDSASigner(signerECDSA3.address); + + // Successfully updates weights and emits event + await expect(this.mock.$_setSignerWeights([signer1, signer2], [5, 5])) + .to.emit(this.mock, 'ERC7913SignerWeightChanged') + .withArgs(signer1, 5) + .to.emit(this.mock, 'ERC7913SignerWeightChanged') + .withArgs(signer2, 5); + + const weight1 = await this.mock.signerWeight(signer1); + const weight2 = await this.mock.signerWeight(signer2); + const weight3 = await this.mock.signerWeight(signer3); + + expect(weight1).to.equal(5); + expect(weight2).to.equal(5); + expect(weight3).to.equal(3); // unchanged + }); + + it('cannot set weight to non-existent signer', async function () { + const randomSigner = encodeECDSASigner(ethers.Wallet.createRandom().address); + + // Reverts when setting weight for non-existent signer + await expect(this.mock.$_setSignerWeights([randomSigner], [1])) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913SignerNonexistentSigner') + .withArgs(randomSigner); + }); + + it('cannot set weight to 0', async function () { + const signer1 = encodeECDSASigner(signerECDSA1.address); + + // Reverts when setting weight to 0 + await expect(this.mock.$_setSignerWeights([signer1], [0])) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913WeightedInvalidWeight') + .withArgs(signer1, 0); + }); + + it('requires signers and weights arrays to have same length', async function () { + const signer1 = encodeECDSASigner(signerECDSA1.address); + const signer2 = encodeECDSASigner(signerECDSA2.address); + + // Reverts when arrays have different lengths + await expect(this.mock.$_setSignerWeights([signer1, signer2], [1])).to.be.revertedWithCustomError( + this.mock, + 'MultiERC7913WeightedMismatchedLength', + ); + }); + + it('validates threshold is reachable when updating weights', async function () { + const signer1 = encodeECDSASigner(signerECDSA1.address); + const signer2 = encodeECDSASigner(signerECDSA2.address); + const signer3 = encodeECDSASigner(signerECDSA3.address); + + // First, lower the weights so the sum is exactly 6 (just enough for threshold=6) + await expect(this.mock.$_setSignerWeights([signer1, signer2, signer3], [1, 2, 3])).to.emit( + this.mock, + 'ERC7913SignerWeightChanged', + ); + + // Increase threshold to 6 + await expect(this.mock.$_setThreshold(6)).to.emit(this.mock, 'ThresholdSet').withArgs(6); + + // Now try to lower weights so their sum is less than the threshold + await expect(this.mock.$_setSignerWeights([signer1, signer2, signer3], [1, 1, 1])).to.be.revertedWithCustomError( + this.mock, + 'MultiERC7913UnreachableThreshold', + ); + }); + + it('reports default weight of 1 for signers without explicit weight', async function () { + const signer4 = encodeECDSASigner(signerECDSA3.address); + + // Add a new signer without setting weight + await this.mock.$_addSigners([signer4]); + + // Should have default weight of 1 + const weight4 = await this.mock.signerWeight(signer4); + expect(weight4).to.equal(1); + }); + }); +}); diff --git a/test/account/AccountZKEmail.test.js b/test/account/AccountZKEmail.test.js index 0531ecf0..9173d2e4 100644 --- a/test/account/AccountZKEmail.test.js +++ b/test/account/AccountZKEmail.test.js @@ -49,9 +49,9 @@ async function fixture() { return Object.assign(userOp, { signature: signer.signingKey.sign(hash).serialized }); }; - const userOpInvalidSig = async userOp => { + const invalidSig = async () => { // Create email auth message for the user operation hash - const hash = await userOp.hash(); + const hash = ethers.ZeroHash; const timestamp = Math.floor(Date.now() / 1000); const command = SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(); const isCodeExist = true; @@ -71,7 +71,7 @@ async function fixture() { ); }; - return { helper, mock, dkim, verifier, target, beneficiary, other, signUserOp, userOpInvalidSig, signer }; + return { helper, mock, dkim, verifier, target, beneficiary, other, signUserOp, invalidSig, signer }; } describe('AccountZKEmail', function () { diff --git a/test/helpers/signers.js b/test/helpers/signers.js index 2e354d47..a3bfb430 100644 --- a/test/helpers/signers.js +++ b/test/helpers/signers.js @@ -15,6 +15,7 @@ const { sha256, toBeHex, toBigInt, + keccak256, } = require('ethers'); const { secp256r1 } = require('@noble/curves/p256'); const { generateKeyPairSync, privateEncrypt } = require('crypto'); @@ -191,6 +192,8 @@ class ZKEmailSigningKey { } sign(digest /*: BytesLike*/ /*: Signature*/) { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + const timestamp = Math.floor(Date.now() / 1000); const command = this.SIGN_HASH_COMMAND + ' ' + toBigInt(digest).toString(); const isCodeExist = true; @@ -222,10 +225,69 @@ class ZKEmailSigningKey { } } +class MultiERC7913SigningKey { + #signers; + #weights; + + constructor(signers, weights = null) { + assertArgument( + Array.isArray(signers) && signers.length > 0, + 'signers must be a non-empty array', + 'signers', + signers.length, + ); + + if (weights !== null) { + assertArgument( + Array.isArray(weights) && weights.length === signers.length, + 'weights must be an array with the same length as signers', + 'weights', + weights.length, + ); + } + + this.#signers = signers; + this.#weights = weights; + } + + get signers() { + return this.#signers; + } + + get weights() { + return this.#weights; + } + + sign(digest /*: BytesLike*/ /*: Signature*/) { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + + const sortedSigners = this.#signers + .map(signer => { + const signerBytes = typeof signer.address === 'string' ? signer.address : signer.bytes; + + const signerId = keccak256(signerBytes); + return { + signerId, + signer: signerBytes, + signature: signer.signingKey.sign(digest).serialized, + }; + }) + .sort((a, b) => (toBigInt(a.signerId) < toBigInt(b.signerId) ? -1 : 1)); + + return { + serialized: AbiCoder.defaultAbiCoder().encode( + ['bytes[]', 'bytes[]'], + [sortedSigners.map(p => p.signer), sortedSigners.map(p => p.signature)], + ), + }; + } +} + module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey, ZKEmailSigningKey, + MultiERC7913SigningKey, }; From 0717223273544ffd378d434473e3d45cfbb46bb1 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 20 Apr 2025 21:32:29 -0600 Subject: [PATCH 02/90] Fix pragma --- contracts/mocks/account/AccountMultiERC7913Mock.sol | 2 +- contracts/mocks/account/AccountMultiERC7913WeightedMock.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/mocks/account/AccountMultiERC7913Mock.sol b/contracts/mocks/account/AccountMultiERC7913Mock.sol index b5a1df82..09e3466b 100644 --- a/contracts/mocks/account/AccountMultiERC7913Mock.sol +++ b/contracts/mocks/account/AccountMultiERC7913Mock.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.27; import {Account} from "../../account/Account.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; diff --git a/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol b/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol index 3520a7d6..190934e3 100644 --- a/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol +++ b/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.27; import {Account} from "../../account/Account.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; From 1a2fad40026774b8541e96c4b58e8fb11720aea0 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 20 Apr 2025 21:34:27 -0600 Subject: [PATCH 03/90] Fix pragma 2 --- contracts/mocks/docs/account/MyAccountMultiERC7913.sol | 2 +- contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/mocks/docs/account/MyAccountMultiERC7913.sol b/contracts/mocks/docs/account/MyAccountMultiERC7913.sol index df6b8446..2796ab05 100644 --- a/contracts/mocks/docs/account/MyAccountMultiERC7913.sol +++ b/contracts/mocks/docs/account/MyAccountMultiERC7913.sol @@ -1,7 +1,7 @@ // contracts/MyAccountERC7913.sol // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.27; import {Account} from "../../../account/Account.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; diff --git a/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol b/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol index 6a7f4216..db9cbd08 100644 --- a/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol +++ b/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol @@ -1,7 +1,7 @@ // contracts/MyAccountERC7913.sol // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.27; import {Account} from "../../../account/Account.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; From 666ab9badcdfabdfbf27bb0f3d6944ef6702a602 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 20 Apr 2025 21:53:01 -0600 Subject: [PATCH 04/90] Add missing tests and fix issue in ERC7913Utils --- contracts/utils/cryptography/ERC7913Utils.sol | 13 ++-- test/account/AccountMultiERC7913.test.js | 63 ++++++++++++++++++- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol index efe7df09..739bb17c 100644 --- a/contracts/utils/cryptography/ERC7913Utils.sol +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -40,13 +40,12 @@ library ERC7913Utils { } else if (signer.length == 20) { return SignatureChecker.isValidSignatureNow(address(bytes20(signer)), hash, signature); } else { - try IERC7913SignatureVerifier(address(bytes20(signer))).verify(signer.slice(20), hash, signature) returns ( - bytes4 magic - ) { - return magic == IERC7913SignatureVerifier.verify.selector; - } catch { - return false; - } + (bool success, bytes memory result) = address(bytes20(signer)).staticcall( + abi.encodeCall(IERC7913SignatureVerifier.verify, (signer.slice(20), hash, signature)) + ); + return (success && + result.length >= 32 && + abi.decode(result, (bytes32)) == bytes32(IERC7913SignatureVerifier.verify.selector)); } } } diff --git a/test/account/AccountMultiERC7913.test.js b/test/account/AccountMultiERC7913.test.js index a7578ecb..24ad11c9 100644 --- a/test/account/AccountMultiERC7913.test.js +++ b/test/account/AccountMultiERC7913.test.js @@ -15,6 +15,7 @@ const { PackedUserOperation } = require('../helpers/eip712-types'); const signerECDSA1 = ethers.Wallet.createRandom(); const signerECDSA2 = ethers.Wallet.createRandom(); const signerECDSA3 = ethers.Wallet.createRandom(); +const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer const signerP256 = new NonNativeSigner(P256SigningKey.random()); const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); @@ -66,6 +67,8 @@ async function fixture() { } describe('AccountMultiERC7913', function () { + const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); + beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); @@ -122,8 +125,6 @@ describe('AccountMultiERC7913', function () { }); describe('Signer management', function () { - const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); - beforeEach(async function () { this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); this.mock = await this.makeMock( @@ -174,4 +175,62 @@ describe('AccountMultiERC7913', function () { ); }); }); + + describe.only('Signature validation', function () { + const TEST_MESSAGE = ethers.keccak256(ethers.toUtf8Bytes('Test message')); + + beforeEach(async function () { + // Set up mock with authorized signers + this.mock = await this.makeMock( + [encodeECDSASigner(signerECDSA1.address), encodeECDSASigner(signerECDSA2.address)], + 1, + ); + await this.mock.deploy(); + }); + + it('rejects signatures from unauthorized signers', async function () { + // Create signatures including an unauthorized signer + const authorizedSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE)); + const unauthorizedSignature = await signerECDSA4.signMessage(ethers.getBytes(TEST_MESSAGE)); + + // Prepare signers and signatures arrays + const signers = [ + encodeECDSASigner(signerECDSA1.address), + encodeECDSASigner(signerECDSA4.address), // Unauthorized signer + ].sort((a, b) => (ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1)); + + const signatures = signers.map(signer => { + if (signer === encodeECDSASigner(signerECDSA1.address)) return authorizedSignature; + return unauthorizedSignature; + }); + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because one signer is not authorized + expect(await this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.be.false; + }); + + it('rejects invalid signatures from authorized signers', async function () { + // Create a valid signature and an invalid one from authorized signers + const validSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE)); + const invalidSignature = await signerECDSA2.signMessage(ethers.toUtf8Bytes('Different message')); // Wrong message + + // Prepare signers and signatures arrays + const signers = [encodeECDSASigner(signerECDSA1.address), encodeECDSASigner(signerECDSA2.address)].sort((a, b) => + ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1, + ); + + const signatures = signers.map(signer => { + if (signer === encodeECDSASigner(signerECDSA1.address)) return validSignature; + return invalidSignature; + }); + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because one signature is invalid + expect(await this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.be.false; + }); + }); }); From 0e69b4cb45938d579397601ce2dfc15d4a813b9b Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 20 Apr 2025 21:57:33 -0600 Subject: [PATCH 05/90] Remove .only --- test/account/AccountMultiERC7913.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/account/AccountMultiERC7913.test.js b/test/account/AccountMultiERC7913.test.js index 24ad11c9..35441e58 100644 --- a/test/account/AccountMultiERC7913.test.js +++ b/test/account/AccountMultiERC7913.test.js @@ -176,7 +176,7 @@ describe('AccountMultiERC7913', function () { }); }); - describe.only('Signature validation', function () { + describe('Signature validation', function () { const TEST_MESSAGE = ethers.keccak256(ethers.toUtf8Bytes('Test message')); beforeEach(async function () { From 0e6eeec015a338828556d27286efb900a444715b Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 20 Apr 2025 22:05:14 -0600 Subject: [PATCH 06/90] nit --- test/account/AccountMultiERC7913.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/account/AccountMultiERC7913.test.js b/test/account/AccountMultiERC7913.test.js index 35441e58..ac4fa2e0 100644 --- a/test/account/AccountMultiERC7913.test.js +++ b/test/account/AccountMultiERC7913.test.js @@ -208,7 +208,7 @@ describe('AccountMultiERC7913', function () { const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); // Should fail because one signer is not authorized - expect(await this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.be.false; + await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false; }); it('rejects invalid signatures from authorized signers', async function () { @@ -230,7 +230,7 @@ describe('AccountMultiERC7913', function () { const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); // Should fail because one signature is invalid - expect(await this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.be.false; + await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false; }); }); }); From 19d2f1ed1b669a69a7de0f93f62ca4efa7f74a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Sun, 20 Apr 2025 22:08:50 -0600 Subject: [PATCH 07/90] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86cbb03a..bcac3d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 20-04-2025 +## 21-04-2025 - `SignerMultiERC7913`: Implementation of `AbstractSigner` that supports multiple ERC-7913 signers with a threshold-based signature verification system. - `SignerMultiERC7913Weighted`: Extension of `SignerMultiERC7913` that supports assigning different weights to each signer, enabling more flexible governance schemes. From e812992ee58077342128ff40358def13a9573b47 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 10:46:06 -0600 Subject: [PATCH 08/90] Update naming --- CHANGELOG.md | 4 ++-- ...913Mock.sol => AccountMultiSignerMock.sol} | 11 ++-------- ...sol => AccountMultiSignerWeightedMock.sol} | 6 +++--- ...tiERC7913.sol => MyAccountMultiSigner.sol} | 8 ++++---- ...d.sol => MyAccountMultiSignerWeighted.sol} | 8 ++++---- contracts/utils/README.adoc | 6 +++--- ...ultiERC7913.sol => MultiSignerERC7913.sol} | 4 ++-- ...ted.sol => MultiSignerERC7913Weighted.sol} | 8 ++++---- docs/modules/ROOT/pages/multisig-account.adoc | 20 +++++++++---------- test/account/AccountMultiERC7913.test.js | 6 +++--- 10 files changed, 37 insertions(+), 44 deletions(-) rename contracts/mocks/account/{AccountMultiERC7913Mock.sol => AccountMultiSignerMock.sol} (79%) rename contracts/mocks/account/{AccountMultiERC7913WeightedMock.sol => AccountMultiSignerWeightedMock.sol} (84%) rename contracts/mocks/docs/account/{MyAccountMultiERC7913.sol => MyAccountMultiSigner.sol} (88%) rename contracts/mocks/docs/account/{MyAccountMultiERC7913Weighted.sol => MyAccountMultiSignerWeighted.sol} (88%) rename contracts/utils/cryptography/{SignerMultiERC7913.sol => MultiSignerERC7913.sol} (98%) rename contracts/utils/cryptography/{SignerMultiERC7913Weighted.sol => MultiSignerERC7913Weighted.sol} (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcac3d15..c43b19f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 21-04-2025 -- `SignerMultiERC7913`: Implementation of `AbstractSigner` that supports multiple ERC-7913 signers with a threshold-based signature verification system. -- `SignerMultiERC7913Weighted`: Extension of `SignerMultiERC7913` that supports assigning different weights to each signer, enabling more flexible governance schemes. +- `MultiSignerERC7913`: Implementation of `AbstractSigner` that supports multiple ERC-7913 signers with a threshold-based signature verification system. +- `MultiSignerERC7913Weighted`: Extension of `MultiSignerERC7913` that supports assigning different weights to each signer, enabling more flexible governance schemes. ## 12-04-2025 diff --git a/contracts/mocks/account/AccountMultiERC7913Mock.sol b/contracts/mocks/account/AccountMultiSignerMock.sol similarity index 79% rename from contracts/mocks/account/AccountMultiERC7913Mock.sol rename to contracts/mocks/account/AccountMultiSignerMock.sol index 09e3466b..aa86ad6e 100644 --- a/contracts/mocks/account/AccountMultiERC7913Mock.sol +++ b/contracts/mocks/account/AccountMultiSignerMock.sol @@ -7,16 +7,9 @@ import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Hol import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import {ERC7739} from "../../utils/cryptography/ERC7739.sol"; import {ERC7821} from "../../account/extensions/ERC7821.sol"; -import {SignerMultiERC7913} from "../../utils/cryptography/SignerMultiERC7913.sol"; +import {MultiSignerERC7913} from "../../utils/cryptography/MultiSignerERC7913.sol"; -abstract contract AccountMultiERC7913Mock is - Account, - SignerMultiERC7913, - ERC7739, - ERC7821, - ERC721Holder, - ERC1155Holder -{ +abstract contract AccountMultiSignerMock is Account, MultiSignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { constructor(bytes[] memory signers, uint256 threshold) { _addSigners(signers); _setThreshold(threshold); diff --git a/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol b/contracts/mocks/account/AccountMultiSignerWeightedMock.sol similarity index 84% rename from contracts/mocks/account/AccountMultiERC7913WeightedMock.sol rename to contracts/mocks/account/AccountMultiSignerWeightedMock.sol index 190934e3..bbfaa76d 100644 --- a/contracts/mocks/account/AccountMultiERC7913WeightedMock.sol +++ b/contracts/mocks/account/AccountMultiSignerWeightedMock.sol @@ -7,11 +7,11 @@ import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Hol import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import {ERC7739} from "../../utils/cryptography/ERC7739.sol"; import {ERC7821} from "../../account/extensions/ERC7821.sol"; -import {SignerMultiERC7913Weighted} from "../../utils/cryptography/SignerMultiERC7913Weighted.sol"; +import {MultiSignerERC7913Weighted} from "../../utils/cryptography/MultiSignerERC7913Weighted.sol"; -abstract contract AccountMultiERC7913WeightedMock is +abstract contract AccountMultiSignerWeightedMock is Account, - SignerMultiERC7913Weighted, + MultiSignerERC7913Weighted, ERC7739, ERC7821, ERC721Holder, diff --git a/contracts/mocks/docs/account/MyAccountMultiERC7913.sol b/contracts/mocks/docs/account/MyAccountMultiSigner.sol similarity index 88% rename from contracts/mocks/docs/account/MyAccountMultiERC7913.sol rename to contracts/mocks/docs/account/MyAccountMultiSigner.sol index 2796ab05..56956828 100644 --- a/contracts/mocks/docs/account/MyAccountMultiERC7913.sol +++ b/contracts/mocks/docs/account/MyAccountMultiSigner.sol @@ -10,18 +10,18 @@ import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155 import {ERC7739} from "../../../utils/cryptography/ERC7739.sol"; import {ERC7821} from "../../../account/extensions/ERC7821.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; -import {SignerMultiERC7913} from "../../../utils/cryptography/SignerMultiERC7913.sol"; +import {MultiSignerERC7913} from "../../../utils/cryptography/MultiSignerERC7913.sol"; -contract MyAccountMultiERC7913 is +contract MyAccountMultiSigner is Account, - SignerMultiERC7913, + MultiSignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder, Initializable { - constructor() EIP712("MyAccountMultiERC7913", "1") {} + constructor() EIP712("MyAccountMultiSigner", "1") {} function initialize(bytes[] memory signers, uint256 threshold) public initializer { _addSigners(signers); diff --git a/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol b/contracts/mocks/docs/account/MyAccountMultiSignerWeighted.sol similarity index 88% rename from contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol rename to contracts/mocks/docs/account/MyAccountMultiSignerWeighted.sol index db9cbd08..c0e10fe9 100644 --- a/contracts/mocks/docs/account/MyAccountMultiERC7913Weighted.sol +++ b/contracts/mocks/docs/account/MyAccountMultiSignerWeighted.sol @@ -10,18 +10,18 @@ import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155 import {ERC7739} from "../../../utils/cryptography/ERC7739.sol"; import {ERC7821} from "../../../account/extensions/ERC7821.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; -import {SignerMultiERC7913Weighted} from "../../../utils/cryptography/SignerMultiERC7913Weighted.sol"; +import {MultiSignerERC7913Weighted} from "../../../utils/cryptography/MultiSignerERC7913Weighted.sol"; -contract MyAccountMultiERC7913Weighted is +contract MyAccountMultiSignerWeighted is Account, - SignerMultiERC7913Weighted, + MultiSignerERC7913Weighted, ERC7739, ERC7821, ERC721Holder, ERC1155Holder, Initializable { - constructor() EIP712("MyAccountMultiERC7913Weighted", "1") {} + constructor() EIP712("MyAccountMultiSignerWeighted", "1") {} function initialize(bytes[] memory signers, uint256[] memory weights, uint256 threshold) public initializer { _addSigners(signers); diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index 25821dc5..4520a8f5 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -10,7 +10,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {ERC7739Utils}: Utilities library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on ERC-7739. * {ERC7913Utils}: utilities library that implements ERC-7913 signature verification with fallback to ERC-1271 and ECDSA. * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. - * {SignerERC7913}, {SignerMultiERC7913}, {SignerMultiERC7913Weighted}: Implementations of {AbstractSigner} that validate signatures based on ERC-7913. Including a simple and weighted multisignature scheme. + * {SignerERC7913}, {MultiSignerERC7913}, {MultiSignerERC7913Weighted}: Implementations of {AbstractSigner} that validate signatures based on ERC-7913. Including a simple and weighted multisignature scheme. * {ERC7913SignatureVerifierP256}, {ERC7913SignatureVerifierRSA}: Ready to use ERC-7913 signature verifiers for P256 and RSA keys * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. * {SignerZKEmail}: Implementation of an {AbstractSigner} that enables email-based authentication through zero-knowledge proofs. @@ -34,9 +34,9 @@ Miscellaneous contracts and libraries containing utility functions you can use t {{SignerERC7913}} -{{SignerMultiERC7913}} +{{MultiSignerERC7913}} -{{SignerMultiERC7913Weighted}} +{{MultiSignerERC7913Weighted}} {{SignerP256}} diff --git a/contracts/utils/cryptography/SignerMultiERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol similarity index 98% rename from contracts/utils/cryptography/SignerMultiERC7913.sol rename to contracts/utils/cryptography/MultiSignerERC7913.sol index 8ceb69a1..9b9e503f 100644 --- a/contracts/utils/cryptography/SignerMultiERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -18,7 +18,7 @@ import {Calldata} from "@openzeppelin/contracts/utils/Calldata.sol"; * Example of usage: * * ```solidity - * contract MyMultiSignerAccount is Account, SignerMultiERC7913, Initializable { + * contract MyMultiSignerAccount is Account, MultiSignerERC7913, Initializable { * constructor() EIP712("MyMultiSignerAccount", "1") {} * * function initialize(bytes[] memory signers, uint256 threshold) public initializer { @@ -44,7 +44,7 @@ import {Calldata} from "@openzeppelin/contracts/utils/Calldata.sol"; * (if used standalone) or during initialization (if used as a clone) may leave the contract * either front-runnable or unusable. */ -abstract contract SignerMultiERC7913 is AbstractSigner { +abstract contract MultiSignerERC7913 is AbstractSigner { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; using ERC7913Utils for bytes; diff --git a/contracts/utils/cryptography/SignerMultiERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol similarity index 95% rename from contracts/utils/cryptography/SignerMultiERC7913Weighted.sol rename to contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 16ffec9a..8585bf7b 100644 --- a/contracts/utils/cryptography/SignerMultiERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -3,11 +3,11 @@ pragma solidity ^0.8.27; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {SignerMultiERC7913} from "./SignerMultiERC7913.sol"; +import {MultiSignerERC7913} from "./MultiSignerERC7913.sol"; import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; /** - * @dev Extension of {SignerMultiERC7913} that supports weighted signatures. + * @dev Extension of {MultiSignerERC7913} that supports weighted signatures. * * This contract allows assigning different weights to each signer, enabling more * flexible governance schemes. For example, some signers could have higher weight @@ -16,7 +16,7 @@ import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.s * Example of usage: * * ```solidity - * contract MyWeightedMultiSignerAccount is Account, SignerMultiERC7913Weighted, Initializable { + * contract MyWeightedMultiSignerAccount is Account, MultiSignerERC7913Weighted, Initializable { * constructor() EIP712("MyWeightedMultiSignerAccount", "1") {} * * function initialize(bytes[] memory signers, uint256[] memory weights, uint256 threshold) public initializer { @@ -47,7 +47,7 @@ import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.s * For example, if signers have weights like 1, 2, or 3, then a threshold of 4 would require at * least two signers (e.g., one with weight 1 and one with weight 3). See {signerWeight}. */ -abstract contract SignerMultiERC7913Weighted is SignerMultiERC7913 { +abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; // Mapping from signer ID to weight diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc index 48eaec80..e495ed0a 100644 --- a/docs/modules/ROOT/pages/multisig-account.adoc +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -34,27 +34,27 @@ include::api:example$account/MyAccountERC7913.sol[] WARNING: Leaving an account uninitialized may leave it unusable since no public key was associated with it. -=== SignerMultiERC7913 +=== MultiSignerERC7913 -The xref:api:utils.adoc#SignerMultiERC7913[`SignerMultiERC7913`] contract extends this concept to support multiple signers with a threshold-based signature verification system. +The xref:api:utils.adoc#MultiSignerERC7913[`MultiSignerERC7913`] contract extends this concept to support multiple signers with a threshold-based signature verification system. [source,solidity] ---- -include::api:example$account/MyAccountMultiERC7913.sol[] +include::api:example$account/MyAccountMultiSigner.sol[] ---- This implementation is ideal for standard multisig setups where each signer has equal authority, and a fixed number of approvals is required. -The `SignerMultiERC7913` contract provides several key features for managing multi-signature accounts. It maintains a set of authorized signers and implements a threshold-based system that requires a minimum number of signatures to approve operations. The contract includes an internal interface for managing signers, allowing for the addition and removal of authorized parties. +The `MultiSignerERC7913` contract provides several key features for managing multi-signature accounts. It maintains a set of authorized signers and implements a threshold-based system that requires a minimum number of signatures to approve operations. The contract includes an internal interface for managing signers, allowing for the addition and removal of authorized parties. -NOTE: `SignerMultiERC7913` safeguards to ensure that the threshold remains achievable based on the current number of active signers, preventing situations where operations could become impossible to execute. +NOTE: `MultiSignerERC7913` safeguards to ensure that the threshold remains achievable based on the current number of active signers, preventing situations where operations could become impossible to execute. -=== SignerMultiERC7913Weighted +=== MultiSignerERC7913Weighted -For more sophisticated governance structures, the xref:api:utils.adoc#SignerMultiERC7913Weighted[`SignerMultiERC7913Weighted`] contract extends `SignerMultiERC7913` by assigning different weights to each signer. +For more sophisticated governance structures, the xref:api:utils.adoc#MultiSignerERC7913Weighted[`MultiSignerERC7913Weighted`] contract extends `MultiSignerERC7913` by assigning different weights to each signer. [source,solidity] ---- -include::api:example$account/MyAccountMultiERC7913Weighted.sol[] +include::api:example$account/MyAccountMultiSignerWeighted.sol[] ---- This implementation is perfect for scenarios where different signers should have varying levels of authority, such as: @@ -63,7 +63,7 @@ This implementation is perfect for scenarios where different signers should have * Organizational structures with hierarchical decision-making * Hybrid governance systems combining core team and community members -The `SignerMultiERC7913Weighted` contract extends `SignerMultiERC7913` with a weighting system. Each signer can have a custom weight, and operations require the total weight of signing participants to meet or exceed the threshold. Signers without explicit weights default to a weight of 1. +The `MultiSignerERC7913Weighted` contract extends `MultiSignerERC7913` with a weighting system. Each signer can have a custom weight, and operations require the total weight of signing participants to meet or exceed the threshold. Signers without explicit weights default to a weight of 1. NOTE: When setting up a weighted multisig, ensure the threshold value 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 at least two signers (e.g., one with weight 1 and one with weight 3). @@ -162,5 +162,5 @@ Where: [NOTE] ==== -For simplicity, `signers` should match in order with `signatures`. And also, to avoid duplicate usage of a single signature, the contract provides an xref:api:utils.adoc#SignerMultiERC7913-signerId-bytes-[`signerId`] function that exposes a single id for each signer. When providing a signature, the `signers` array should be in ascending order by `signerId`. +For simplicity, `signers` should match in order with `signatures`. And also, to avoid duplicate usage of a single signature, the contract provides an xref:api:utils.adoc#MultiSignerERC7913-signerId-bytes-[`signerId`] function that exposes a single id for each signer. When providing a signature, the `signers` array should be in ascending order by `signerId`. ==== diff --git a/test/account/AccountMultiERC7913.test.js b/test/account/AccountMultiERC7913.test.js index ac4fa2e0..40c7db8e 100644 --- a/test/account/AccountMultiERC7913.test.js +++ b/test/account/AccountMultiERC7913.test.js @@ -33,10 +33,10 @@ async function fixture() { const helper = new ERC4337Helper(); await helper.wait(); const entrypointDomain = await getDomain(entrypoint.v08); - const domain = { name: 'AccountMultiERC7913', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract + const domain = { name: 'AccountMultiSigner', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract const makeMock = (signers, threshold) => - helper.newAccount('$AccountMultiERC7913Mock', ['AccountMultiERC7913', '1', signers, threshold]).then(mock => { + helper.newAccount('$AccountMultiSignerMock', ['AccountMultiSigner', '1', signers, threshold]).then(mock => { domain.verifyingContract = mock.address; return mock; }); @@ -66,7 +66,7 @@ async function fixture() { }; } -describe('AccountMultiERC7913', function () { +describe('AccountMultiSigner', function () { const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); beforeEach(async function () { From dd213b47f87eb650ba3ff2ad424eeb33e8f17b43 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 12:29:56 -0600 Subject: [PATCH 09/90] Fix tests --- ...countMultiERC7913.test.js => AccountMultiSigner.test.js} | 0 ...3Weighted.test.js => AccountMultiSignerWeighted.test.js} | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename test/account/{AccountMultiERC7913.test.js => AccountMultiSigner.test.js} (100%) rename test/account/{AccountMultiERC7913Weighted.test.js => AccountMultiSignerWeighted.test.js} (96%) diff --git a/test/account/AccountMultiERC7913.test.js b/test/account/AccountMultiSigner.test.js similarity index 100% rename from test/account/AccountMultiERC7913.test.js rename to test/account/AccountMultiSigner.test.js diff --git a/test/account/AccountMultiERC7913Weighted.test.js b/test/account/AccountMultiSignerWeighted.test.js similarity index 96% rename from test/account/AccountMultiERC7913Weighted.test.js rename to test/account/AccountMultiSignerWeighted.test.js index 5277e44a..93d4bb22 100644 --- a/test/account/AccountMultiERC7913Weighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -32,11 +32,11 @@ async function fixture() { const helper = new ERC4337Helper(); await helper.wait(); const entrypointDomain = await getDomain(entrypoint.v08); - const domain = { name: 'AccountMultiERC7913Weighted', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract + const domain = { name: 'AccountMultiSignerWeighted', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract const makeMock = (signers, weights, threshold) => helper - .newAccount('$AccountMultiERC7913WeightedMock', ['AccountMultiERC7913Weighted', '1', signers, weights, threshold]) + .newAccount('$AccountMultiSignerWeightedMock', ['AccountMultiSignerWeighted', '1', signers, weights, threshold]) .then(mock => { domain.verifyingContract = mock.address; return mock; @@ -67,7 +67,7 @@ async function fixture() { }; } -describe('AccountMultiERC7913Weighted', function () { +describe('AccountMultiSignerWeighted', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); From 28c2a9962e38a175259b69c57ffbe36e02432741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 23 Apr 2025 13:34:16 -0600 Subject: [PATCH 10/90] Update docs/modules/ROOT/pages/multisig-account.adoc Co-authored-by: Gonzalo Othacehe <86085168+gonzaotc@users.noreply.github.com> --- docs/modules/ROOT/pages/multisig-account.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc index e495ed0a..912f69b0 100644 --- a/docs/modules/ROOT/pages/multisig-account.adoc +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -21,7 +21,7 @@ The https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts == ERC-7913 Signers -https://eips.ethereum.org/EIPS/eip-7913[ERC-7913] addresses these limitations by extending the concept of signer representation to include keys that don't have EVM addresses. OpenZeppelin implements this standard through three contracts: +https://eips.ethereum.org/EIPS/eip-7913[ERC-7913] extends the concept of signer representation to include keys that don't have EVM addresses, addressing this limitation. OpenZeppelin implements this standard through three contracts: === SignerERC7913 From da1f4ff56fc195fa879ba6b0edb27b7eadc8afb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 23 Apr 2025 13:37:56 -0600 Subject: [PATCH 11/90] Update docs/modules/ROOT/pages/multisig-account.adoc Co-authored-by: Gonzalo Othacehe <86085168+gonzaotc@users.noreply.github.com> --- docs/modules/ROOT/pages/multisig-account.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc index 912f69b0..7afab0cc 100644 --- a/docs/modules/ROOT/pages/multisig-account.adoc +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -62,6 +62,7 @@ This implementation is perfect for scenarios where different signers should have * Board members with different voting powers * Organizational structures with hierarchical decision-making * Hybrid governance systems combining core team and community members +* Execution setups like "social recovery" where you trust particular guardians more than others The `MultiSignerERC7913Weighted` contract extends `MultiSignerERC7913` with a weighting system. Each signer can have a custom weight, and operations require the total weight of signing participants to meet or exceed the threshold. Signers without explicit weights default to a weight of 1. From 0d53def35084192f862c37a4930b858b85681b2d Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 13:41:18 -0600 Subject: [PATCH 12/90] Rename errors --- contracts/utils/cryptography/MultiSignerERC7913.sol | 8 ++++---- .../utils/cryptography/MultiSignerERC7913Weighted.sol | 4 ++-- test/account/AccountMultiSigner.test.js | 4 ++-- test/account/AccountMultiSignerWeighted.test.js | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index 9b9e503f..8c488f3a 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -58,10 +58,10 @@ abstract contract MultiSignerERC7913 is AbstractSigner { event ThresholdSet(uint256 threshold); /// @dev The `signer` already exists. - error MultiERC7913SignerAlreadyExists(bytes signer); + error MultiSignerERC7913AlreadyExists(bytes signer); /// @dev The `signer` does not exist. - error MultiERC7913SignerNonexistentSigner(bytes signer); + error MultiSignerERC7913NonexistentSigner(bytes signer); /// @dev The `signer` is less than 20 bytes long. error MultiERC7913InvalidSigner(bytes signer); @@ -92,7 +92,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; require(signer.length >= 20, MultiERC7913InvalidSigner(signer)); - require(_signersSet.add(signer), MultiERC7913SignerAlreadyExists(signer)); + require(_signersSet.add(signer), MultiSignerERC7913AlreadyExists(signer)); } emit ERC7913SignersAdded(signers); } @@ -101,7 +101,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { function _removeSigners(bytes[] memory signers) internal virtual { for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; - require(_signersSet.remove(signer), MultiERC7913SignerNonexistentSigner(signer)); + require(_signersSet.remove(signer), MultiSignerERC7913NonexistentSigner(signer)); } _validateReachableThreshold(); emit ERC7913SignersRemoved(signers); diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 8585bf7b..11447551 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -72,7 +72,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { * Requirements: * * - `signers` and `weights` arrays must have the same length. Reverts with {MultiERC7913WeightedMismatchedLength} on mismatch. - * - Each signer must exist in the set of authorized signers. Reverts with {MultiERC7913SignerNonexistentSigner} if not. + * - Each signer must exist in the set of authorized signers. Reverts with {MultiSignerERC7913NonexistentSigner} if not. * - Each weight must be greater than 0. Reverts with {MultiERC7913WeightedInvalidWeight} if not. */ function _setSignerWeights(bytes[] memory signers, uint256[] memory weights) internal virtual { @@ -81,7 +81,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; uint256 weight = weights[i]; - require(_signers().contains(signer), MultiERC7913SignerNonexistentSigner(signer)); + require(_signers().contains(signer), MultiSignerERC7913NonexistentSigner(signer)); require(weight > 0, MultiERC7913WeightedInvalidWeight(signer, weight)); _weights[signerId(signer)] = weight; diff --git a/test/account/AccountMultiSigner.test.js b/test/account/AccountMultiSigner.test.js index 40c7db8e..40a4b7a0 100644 --- a/test/account/AccountMultiSigner.test.js +++ b/test/account/AccountMultiSigner.test.js @@ -146,7 +146,7 @@ describe('AccountMultiSigner', function () { // Reverts if the signer was already added await expect(this.mock.$_addSigners(signers)) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913SignerAlreadyExists') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913AlreadyExists') .withArgs(...signers); }); @@ -160,7 +160,7 @@ describe('AccountMultiSigner', function () { // Reverts removing a signer if it doesn't exist await expect(this.mock.$_removeSigners(signers)) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913SignerNonexistentSigner') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner') .withArgs(...signers); }); diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index 93d4bb22..0981eed7 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -192,7 +192,7 @@ describe('AccountMultiSignerWeighted', function () { // Reverts when setting weight for non-existent signer await expect(this.mock.$_setSignerWeights([randomSigner], [1])) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913SignerNonexistentSigner') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner') .withArgs(randomSigner); }); From 26e7e115423e40421507366ccc22abe8a3336083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 23 Apr 2025 13:50:48 -0600 Subject: [PATCH 13/90] Update docs/modules/ROOT/pages/multisig-account.adoc Co-authored-by: Gonzalo Othacehe <86085168+gonzaotc@users.noreply.github.com> --- docs/modules/ROOT/pages/multisig-account.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc index 7afab0cc..5b7c4837 100644 --- a/docs/modules/ROOT/pages/multisig-account.adoc +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -138,7 +138,7 @@ function setupWeightedMultisigAccount() external { } ---- -IMPORTANT: Always ensure that the sum of weights for all active signers meets or exceeds the threshold. Otherwise, it would be impossible to reach the required threshold for approving operations. +IMPORTANT: The xref:api:utils.adoc#MultiSignerERC7913-_validateReachableThreshold--[`_validateReachableThreshold`] function ensures that the sum of weights for all active signers meets or exceeds the threshold. Any customization built on top of the multisigner contracts must ensure the threshold is always reachable. == How to Encode a Signature From 1ca0f1e96d50997d07dd60ae21a97b148c22f149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 23 Apr 2025 13:51:10 -0600 Subject: [PATCH 14/90] Update docs/modules/ROOT/pages/multisig-account.adoc Co-authored-by: Gonzalo Othacehe <86085168+gonzaotc@users.noreply.github.com> --- docs/modules/ROOT/pages/multisig-account.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc index 5b7c4837..51f211cd 100644 --- a/docs/modules/ROOT/pages/multisig-account.adoc +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -140,7 +140,7 @@ function setupWeightedMultisigAccount() external { IMPORTANT: The xref:api:utils.adoc#MultiSignerERC7913-_validateReachableThreshold--[`_validateReachableThreshold`] function ensures that the sum of weights for all active signers meets or exceeds the threshold. Any customization built on top of the multisigner contracts must ensure the threshold is always reachable. -== How to Encode a Signature +== How to Encode a Multisignature For multisig accounts, the signature is a complex structure that contains both the signers and their individual signatures. The format follows ERC-7913's specification and must be properly encoded. From 2728bff5557dca34b6e6aa40c1d9b1b2544e2d90 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 14:11:58 -0600 Subject: [PATCH 15/90] Implement review suggestions --- .../MultiSignerERC7913Weighted.sol | 35 +++++++++++-------- docs/modules/ROOT/pages/multisig-account.adoc | 4 +-- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 11447551..5c93068f 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -91,25 +91,30 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { _validateReachableThreshold(); } - /// @dev Sets the threshold for the multisignature operation. Internal version without access control. + /** + * @dev Sets the threshold for the multisignature operation. Internal version without access control. + * + * 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() internal view virtual override { - // This override intentionally does not call `super._validateReachableThreshold` since that would - // perform a comparison of `signers.length >= _threshold`, which is the less-weight per signer - // scenario. This would cause a duplicated and unnecessary SLOAD. Since `_validateReachableThreshold` is - // a `view` function, there are no state changes that we would miss by not calling super. - require( - _weightSigners(_signers().values()) >= _threshold(), // TODO: Should there be a max signers? - MultiERC7913UnreachableThreshold(_signers().length(), _threshold()) - ); + bytes[] memory allSigners = _signers().values(); // TODO: Should there be a max signers? + uint256 totalWeight = _weightSigners(allSigners); + require(totalWeight >= _threshold(), MultiERC7913UnreachableThreshold(totalWeight, _threshold())); } - /// @dev Overrides the threshold validation to use signer weights. + /** + * @dev Overrides the threshold validation to use signer weights. + * + * 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 _validateThreshold(bytes[] memory signers) internal view virtual override returns (bool) { - // This override intentionally does not call `super._validateThreshold` since that would - // perform a comparison of `signers.length >= _threshold`, which is the less-weight per signer - // scenario. This would cause a duplicated and unnecessary SLOAD. Since `_validateThreshold` is - // a `view` function, there are no state changes that we would miss by not calling super. - return _weightSigners(signers) >= _threshold(); /* || super._validateThreshold(signers) */ + return _weightSigners(signers) >= _threshold(); } /// @dev Calculates the total weight of a set of signers. diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc index 51f211cd..d4ca8ef6 100644 --- a/docs/modules/ROOT/pages/multisig-account.adoc +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -151,7 +151,7 @@ The multisig signature is encoded as: [source,solidity] ---- abi.encode( - bytes[] signers, // Array of signers in order + bytes[] signers, // Array of signers sorted by `signerId` bytes[] signatures // Array of signatures corresponding to each signer ) ---- @@ -163,5 +163,5 @@ Where: [NOTE] ==== -For simplicity, `signers` should match in order with `signatures`. And also, to avoid duplicate usage of a single signature, the contract provides an xref:api:utils.adoc#MultiSignerERC7913-signerId-bytes-[`signerId`] function that exposes a single id for each signer. When providing a signature, the `signers` array should be in ascending order by `signerId`. +To avoid duplicate signers, the contract provides an xref:api:utils.adoc#MultiSignerERC7913-signerId-bytes-[`signerId`] function that exposes a unique id for each signer. When providing a multisignature, the `signers` array must be sorted in ascending order by `signerId`, and the `signatures` array must match the order of their corresponding signers. ==== From 2ba9a1157d145e8d448f83bb98ab9dc1cb2a1831 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 14:25:35 -0600 Subject: [PATCH 16/90] Unskip tests --- test/account/AccountMultiSignerWeighted.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index 0981eed7..5ae77819 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -15,6 +15,7 @@ const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); const signerECDSA1 = ethers.Wallet.createRandom(); const signerECDSA2 = ethers.Wallet.createRandom(); const signerECDSA3 = ethers.Wallet.createRandom(); +const signerECDSA4 = ethers.Wallet.createRandom(); const signerP256 = new NonNativeSigner(P256SigningKey.random()); const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); @@ -132,7 +133,7 @@ describe('AccountMultiSignerWeighted', function () { shouldBehaveLikeERC7821(); }); - describe.skip('Weight management', function () { + describe('Weight management', function () { const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); beforeEach(async function () { @@ -238,7 +239,7 @@ describe('AccountMultiSignerWeighted', function () { }); it('reports default weight of 1 for signers without explicit weight', async function () { - const signer4 = encodeECDSASigner(signerECDSA3.address); + const signer4 = encodeECDSASigner(signerECDSA4.address); // Add a new signer without setting weight await this.mock.$_addSigners([signer4]); From 96c64057a0abf4e7527fd5501378194b3aa6df48 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 21:22:05 -0600 Subject: [PATCH 17/90] Address review comments --- .../MultiSignerERC7913Weighted.sol | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 5c93068f..9bd31306 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -56,13 +56,23 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { /// @dev Emitted when a signer's weight is changed. event ERC7913SignerWeightChanged(bytes indexed signer, uint256 weight); - /// @dev Emitted when a signer's weight is invalid. + /// @dev Thrown when a signer's weight is invalid. error MultiERC7913WeightedInvalidWeight(bytes signer, uint256 weight); + /// @dev Thrown when the threshold is unreachable. error MultiERC7913WeightedMismatchedLength(); - /// @dev Gets the weight of a signer. Returns 1 if not explicitly set. + /// @dev Gets the weight of a signer. Returns 0 if the signer is not authorized. function signerWeight(bytes memory signer) public view virtual returns (uint256) { + return Math.ternary(_signers().contains(signer), _signerWeight(signer), 0); + } + + /** + * @dev Gets the weight of the current signer. Returns 1 if not explicitly set. + * + * NOTE: This internal function doesn't check if the signer is authorized. + */ + function _signerWeight(bytes memory signer) internal view virtual returns (uint256) { return Math.max(_weights[signerId(signer)], 1); } @@ -117,6 +127,16 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { return _weightSigners(signers) >= _threshold(); } + /// @inheritdoc MultiSignerERC7913 + function _removeSigners(bytes[] memory signers) internal virtual override { + super._removeSigners(signers); + // Clean up weights for removed signers + for (uint256 i = 0; i < signers.length; i++) { + delete _weights[signerId(signers[i])]; + emit ERC7913SignerWeightChanged(signers[i], 0); + } + } + /// @dev Calculates the total weight of a set of signers. function _weightSigners(bytes[] memory signers) internal view virtual returns (uint256) { uint256 totalWeight = 0; From cdafc94ab33dcef7c6e0ff404f7dc9a060d04f45 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 22:24:52 -0600 Subject: [PATCH 18/90] up --- .../utils/cryptography/MultiSignerERC7913.sol | 69 +++++++++++-------- .../MultiSignerERC7913Weighted.sol | 53 +++++++++----- test/account/AccountMultiSigner.test.js | 18 +++++ .../AccountMultiSignerWeighted.test.js | 43 +++++++----- 4 files changed, 120 insertions(+), 63 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index 8c488f3a..ed3d29a9 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -70,55 +70,66 @@ abstract contract MultiSignerERC7913 is AbstractSigner { error MultiERC7913UnreachableThreshold(uint256 signers, uint256 threshold); EnumerableSetExtended.BytesSet private _signersSet; - uint256 private _minSigners; + uint256 private _threshold; /// @dev Returns the internal id of the `signer`. function signerId(bytes memory signer) public view virtual returns (bytes32) { return keccak256(signer); } - /// @dev Returns the set of authorized signers. - function _signers() internal view virtual returns (EnumerableSetExtended.BytesSet storage) { - return _signersSet; + /** + * @dev Returns the set of authorized signers. Prefer {_signers} for internal use. + * + * WARNING: This operation copies the entire signers set to memory, which can be expensive. This is designed + * for view accessors queried without gas fees. Using it in state-changing functions may become uncallable + * if the signers set grows too large. + */ + function signers() public view virtual returns (bytes[] memory) { + return _signersSet.values(); } /// @dev Returns the minimum number of signers required to approve a multisignature operation. - function _threshold() internal view virtual returns (uint256) { - return _minSigners; + function threshold() public view virtual returns (uint256) { + return _threshold; + } + + /// @dev Returns the set of authorized signers. + function _signers() internal view virtual returns (EnumerableSetExtended.BytesSet storage) { + return _signersSet; } - /// @dev Adds the `signers` to those allowed to sign on behalf of this contract. Internal version without access control. - function _addSigners(bytes[] memory signers) internal virtual { - for (uint256 i = 0; i < signers.length; i++) { - bytes memory signer = signers[i]; + /// @dev Adds the `newSigners` to those allowed to sign on behalf of this contract. Internal version without access control. + function _addSigners(bytes[] memory newSigners) internal virtual { + for (uint256 i = 0; i < newSigners.length; i++) { + bytes memory signer = newSigners[i]; require(signer.length >= 20, MultiERC7913InvalidSigner(signer)); require(_signersSet.add(signer), MultiSignerERC7913AlreadyExists(signer)); } - emit ERC7913SignersAdded(signers); + emit ERC7913SignersAdded(newSigners); } - /// @dev Removes the `signers` from the authorized signers. Internal version without access control. - function _removeSigners(bytes[] memory signers) internal virtual { - for (uint256 i = 0; i < signers.length; i++) { - bytes memory signer = signers[i]; + /// @dev Removes the `oldSigners` from the authorized signers. Internal version without access control. + function _removeSigners(bytes[] memory oldSigners) internal virtual { + for (uint256 i = 0; i < oldSigners.length; i++) { + bytes memory signer = oldSigners[i]; require(_signersSet.remove(signer), MultiSignerERC7913NonexistentSigner(signer)); } _validateReachableThreshold(); - emit ERC7913SignersRemoved(signers); + emit ERC7913SignersRemoved(oldSigners); } /// @dev Sets the signatures `threshold` required to approve a multisignature operation. Internal version without access control. function _setThreshold(uint256 threshold_) internal virtual { - _minSigners = threshold_; + _threshold = threshold_; _validateReachableThreshold(); emit ThresholdSet(threshold_); } /// @dev Validates the current threshold is reachable. function _validateReachableThreshold() internal view virtual { - uint256 signers = _signers().length(); - uint256 threshold = _threshold(); - require(signers >= threshold, MultiERC7913UnreachableThreshold(signers, threshold)); + uint256 totalSigners = _signers().length(); + uint256 minThreshold = threshold(); + require(totalSigners >= minThreshold, MultiERC7913UnreachableThreshold(totalSigners, minThreshold)); } /** @@ -155,9 +166,9 @@ abstract contract MultiSignerERC7913 is AbstractSigner { bytes calldata signature ) internal view virtual override returns (bool) { if (signature.length == 0) return false; // For ERC-7739 compatibility - (bytes[] memory signers, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); - if (signers.length != signatures.length) return false; - return _validateNSignatures(hash, signers, signatures) && _validateThreshold(signers); + (bytes[] memory signingSigners, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); + if (signingSigners.length != signatures.length) return false; + return _validateNSignatures(hash, signingSigners, signatures) && _validateThreshold(signingSigners); } /** @@ -174,15 +185,15 @@ abstract contract MultiSignerERC7913 is AbstractSigner { */ function _validateNSignatures( bytes32 hash, - bytes[] memory signers, + bytes[] memory signingSigners, bytes[] memory signatures ) internal view virtual returns (bool valid) { bytes32 currentSignerId = bytes32(0); - uint256 signersLength = signers.length; + uint256 signersLength = signingSigners.length; for (uint256 i = 0; i < signersLength; i++) { // Signers must ordered by id to ensure no duplicates - bytes memory signer = signers[i]; + bytes memory signer = signingSigners[i]; bytes32 id = signerId(signer); if ( currentSignerId >= id || @@ -199,10 +210,10 @@ abstract contract MultiSignerERC7913 is AbstractSigner { } /** - * @dev Validates that the number of signers meets the {_threshold} requirement. + * @dev Validates that the number of signers meets the {threshold} requirement. * Assumes the signers were already validated. See {_validateNSignatures} for more details. */ - function _validateThreshold(bytes[] memory validatedSigners) internal view virtual returns (bool) { - return validatedSigners.length >= _threshold(); + function _validateThreshold(bytes[] memory validatingSigners) internal view virtual returns (bool) { + return validatingSigners.length >= threshold(); } } diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 9bd31306..5bbb5990 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -50,6 +50,9 @@ import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.s abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + // Invariant: sum(weights) == threshold + uint256 private _totalWeight; + // Mapping from signer ID to weight mapping(bytes32 signedId => uint256) private _weights; @@ -67,6 +70,11 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { return Math.ternary(_signers().contains(signer), _signerWeight(signer), 0); } + /// @dev Gets the total weight of all signers. + function totalWeight() public view virtual returns (uint256) { + return _totalWeight; + } + /** * @dev Gets the weight of the current signer. Returns 1 if not explicitly set. * @@ -94,13 +102,33 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { require(_signers().contains(signer), MultiSignerERC7913NonexistentSigner(signer)); require(weight > 0, MultiERC7913WeightedInvalidWeight(signer, weight)); + uint256 oldWeight = _signerWeight(signer); _weights[signerId(signer)] = weight; + _totalWeight = _totalWeight - oldWeight + weight; emit ERC7913SignerWeightChanged(signer, weight); } _validateReachableThreshold(); } + /// @inheritdoc MultiSignerERC7913 + function _addSigners(bytes[] memory newSigners) internal virtual override { + super._addSigners(newSigners); + _totalWeight += newSigners.length; // Each new signer has a default weight of 1 + } + + /// @inheritdoc MultiSignerERC7913 + function _removeSigners(bytes[] memory signers) internal virtual override { + uint256 removedWeight = _weightSigners(signers); + super._removeSigners(signers); + _totalWeight -= removedWeight; + // Clean up weights for removed signers + for (uint256 i = 0; i < signers.length; i++) { + delete _weights[signerId(signers[i])]; + emit ERC7913SignerWeightChanged(signers[i], 0); + } + } + /** * @dev Sets the threshold for the multisignature operation. Internal version without access control. * @@ -110,9 +138,8 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { * depending on the linearization order. */ function _validateReachableThreshold() internal view virtual override { - bytes[] memory allSigners = _signers().values(); // TODO: Should there be a max signers? - uint256 totalWeight = _weightSigners(allSigners); - require(totalWeight >= _threshold(), MultiERC7913UnreachableThreshold(totalWeight, _threshold())); + uint256 weight = totalWeight(); + require(weight >= threshold(), MultiERC7913UnreachableThreshold(weight, threshold())); } /** @@ -124,26 +151,16 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { * depending on the linearization order. */ function _validateThreshold(bytes[] memory signers) internal view virtual override returns (bool) { - return _weightSigners(signers) >= _threshold(); - } - - /// @inheritdoc MultiSignerERC7913 - function _removeSigners(bytes[] memory signers) internal virtual override { - super._removeSigners(signers); - // Clean up weights for removed signers - for (uint256 i = 0; i < signers.length; i++) { - delete _weights[signerId(signers[i])]; - emit ERC7913SignerWeightChanged(signers[i], 0); - } + return _weightSigners(signers) >= threshold(); } - /// @dev Calculates the total weight of a set of signers. + /// @dev Calculates the total weight of a set of signers. For all signers weight use {totalWeight}. function _weightSigners(bytes[] memory signers) internal view virtual returns (uint256) { - uint256 totalWeight = 0; + uint256 weight = 0; uint256 signersLength = signers.length; for (uint256 i = 0; i < signersLength; i++) { - totalWeight += signerWeight(signers[i]); + weight += signerWeight(signers[i]); } - return totalWeight; + return weight; } } diff --git a/test/account/AccountMultiSigner.test.js b/test/account/AccountMultiSigner.test.js index 40a4b7a0..8068fd6f 100644 --- a/test/account/AccountMultiSigner.test.js +++ b/test/account/AccountMultiSigner.test.js @@ -174,6 +174,24 @@ describe('AccountMultiSigner', function () { 'MultiERC7913UnreachableThreshold', ); }); + + it('rejects invalid signer format', async function () { + const invalidSigner = '0x123456'; // Too short + + await expect(this.mock.$_addSigners([invalidSigner])) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913InvalidSigner') + .withArgs(invalidSigner); + }); + + it('can read signers and threshold', async function () { + const signersArray = await this.mock.signers(); + expect(signersArray).to.have.lengthOf(2); + expect(signersArray).to.include(encodeECDSASigner(signerECDSA1.address)); + expect(signersArray).to.include(encodeECDSASigner(signerECDSA2.address)); + + const currentThreshold = await this.mock.threshold(); + expect(currentThreshold).to.equal(1); + }); }); describe('Signature validation', function () { diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index 5ae77819..a8503beb 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -158,13 +158,9 @@ describe('AccountMultiSignerWeighted', function () { const signer2 = encodeECDSASigner(signerECDSA2.address); const signer3 = encodeECDSASigner(signerECDSA3.address); - const weight1 = await this.mock.signerWeight(signer1); - const weight2 = await this.mock.signerWeight(signer2); - const weight3 = await this.mock.signerWeight(signer3); - - expect(weight1).to.equal(1); - expect(weight2).to.equal(2); - expect(weight3).to.equal(3); + await expect(this.mock.signerWeight(signer1)).to.eventually.equal(1); + await expect(this.mock.signerWeight(signer2)).to.eventually.equal(2); + await expect(this.mock.signerWeight(signer3)).to.eventually.equal(3); }); it('can update signer weights', async function () { @@ -179,13 +175,9 @@ describe('AccountMultiSignerWeighted', function () { .to.emit(this.mock, 'ERC7913SignerWeightChanged') .withArgs(signer2, 5); - const weight1 = await this.mock.signerWeight(signer1); - const weight2 = await this.mock.signerWeight(signer2); - const weight3 = await this.mock.signerWeight(signer3); - - expect(weight1).to.equal(5); - expect(weight2).to.equal(5); - expect(weight3).to.equal(3); // unchanged + await expect(this.mock.signerWeight(signer1)).to.eventually.equal(5); + await expect(this.mock.signerWeight(signer2)).to.eventually.equal(5); + await expect(this.mock.signerWeight(signer3)).to.eventually.equal(3); // unchanged }); it('cannot set weight to non-existent signer', async function () { @@ -245,8 +237,27 @@ describe('AccountMultiSignerWeighted', function () { await this.mock.$_addSigners([signer4]); // Should have default weight of 1 - const weight4 = await this.mock.signerWeight(signer4); - expect(weight4).to.equal(1); + expect(this.mock.signerWeight(signer4)).to.eventually.equal(1); + }); + + it('can get total weight of all signers', async function () { + expect(this.mock.totalWeight()).to.eventually.equal(6); // 1 + 2 + 3 + }); + + it('updates total weight when adding and removing signers', async function () { + const signer4 = encodeECDSASigner(signerECDSA4.address); + + // Add a new signer - should increase total weight by default weight (1) + await this.mock.$_addSigners([signer4]); + expect(this.mock.totalWeight()).to.eventually.equal(7); // 6 + 1 + + // Set weight to 5 - should increase total weight by 4 + await this.mock.$_setSignerWeights([signer4], [5]); + expect(this.mock.totalWeight()).to.eventually.equal(11); // 7 + 4 + + // Remove signer - should decrease total weight by current weight (5) + await this.mock.$_removeSigners([signer4]); + expect(this.mock.totalWeight()).to.eventually.equal(6); // 11 - 5 }); }); }); From 7c6fbd48556796b297eebb2e37b45afdfe8e7b38 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 23:11:09 -0600 Subject: [PATCH 19/90] Nits --- test/account/AccountMultiSignerWeighted.test.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index a8503beb..e46ddcf3 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -153,6 +153,11 @@ describe('AccountMultiSignerWeighted', function () { await this.mock.deploy(); }); + it('verifies signerId function returns keccak256(signer)', async function () { + const signer = encodeECDSASigner(signerECDSA1.address); + await expect(this.mock.signerId(signer)).to.eventually.equal(ethers.keccak256(signer)); + }); + it('can get signer weights', async function () { const signer1 = encodeECDSASigner(signerECDSA1.address); const signer2 = encodeECDSASigner(signerECDSA2.address); @@ -237,11 +242,11 @@ describe('AccountMultiSignerWeighted', function () { await this.mock.$_addSigners([signer4]); // Should have default weight of 1 - expect(this.mock.signerWeight(signer4)).to.eventually.equal(1); + await expect(this.mock.signerWeight(signer4)).to.eventually.equal(1); }); it('can get total weight of all signers', async function () { - expect(this.mock.totalWeight()).to.eventually.equal(6); // 1 + 2 + 3 + await expect(this.mock.totalWeight()).to.eventually.equal(6); // 1 + 2 + 3 }); it('updates total weight when adding and removing signers', async function () { @@ -249,15 +254,15 @@ describe('AccountMultiSignerWeighted', function () { // Add a new signer - should increase total weight by default weight (1) await this.mock.$_addSigners([signer4]); - expect(this.mock.totalWeight()).to.eventually.equal(7); // 6 + 1 + await expect(this.mock.totalWeight()).to.eventually.equal(7); // 6 + 1 // Set weight to 5 - should increase total weight by 4 await this.mock.$_setSignerWeights([signer4], [5]); - expect(this.mock.totalWeight()).to.eventually.equal(11); // 7 + 4 + await expect(this.mock.totalWeight()).to.eventually.equal(11); // 7 + 4 // Remove signer - should decrease total weight by current weight (5) await this.mock.$_removeSigners([signer4]); - expect(this.mock.totalWeight()).to.eventually.equal(6); // 11 - 5 + await expect(this.mock.totalWeight()).to.eventually.equal(6); // 11 - 5 }); }); }); From 9634d0f812551ec8865905a3f4b5f0ecce0d866a Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 23 Apr 2025 23:48:30 -0600 Subject: [PATCH 20/90] Fix tests --- test/account/AccountMultiSigner.test.js | 52 +++++++++---------- .../AccountMultiSignerWeighted.test.js | 48 +++++++---------- 2 files changed, 43 insertions(+), 57 deletions(-) diff --git a/test/account/AccountMultiSigner.test.js b/test/account/AccountMultiSigner.test.js index 8068fd6f..a3a070be 100644 --- a/test/account/AccountMultiSigner.test.js +++ b/test/account/AccountMultiSigner.test.js @@ -67,8 +67,6 @@ async function fixture() { } describe('AccountMultiSigner', function () { - const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); - beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); @@ -127,41 +125,42 @@ describe('AccountMultiSigner', function () { describe('Signer management', function () { beforeEach(async function () { this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); - this.mock = await this.makeMock( - [encodeECDSASigner(signerECDSA1.address), encodeECDSASigner(signerECDSA2.address)], - 1, - ); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1); await this.mock.deploy(); }); it('can add signers', async function () { const signers = [ - encodeECDSASigner(signerECDSA3.address), // ECDSA Signer + signerECDSA3.address, // ECDSA Signer ]; // Successfully adds a signer - await expect(this.mock.$_addSigners(signers)) - .to.emit(this.mock, 'ERC7913SignersAdded') - .withArgs(...signers); + const signersArrayBefore = await this.mock.signers().then(s => s.map(ethers.getAddress)); + await expect(this.mock.$_addSigners(signers)).to.emit(this.mock, 'ERC7913SignersAdded'); + const signersArrayAfter = await this.mock.signers().then(s => s.map(ethers.getAddress)); + expect(signersArrayAfter.length).to.equal(signersArrayBefore.length + 1); + expect(signersArrayAfter).to.include(ethers.getAddress(signerECDSA3.address)); // Reverts if the signer was already added await expect(this.mock.$_addSigners(signers)) .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913AlreadyExists') - .withArgs(...signers); + .withArgs(...signers.map(s => s.toLowerCase())); }); it('can remove signers', async function () { - const signers = [encodeECDSASigner(signerECDSA2.address)]; + const signers = [signerECDSA2.address]; // Successfully removes an already added signer - await expect(this.mock.$_removeSigners(signers)) - .to.emit(this.mock, 'ERC7913SignersRemoved') - .withArgs(...signers); + const signersArrayBefore = await this.mock.signers().then(s => s.map(ethers.getAddress)); + await expect(this.mock.$_removeSigners(signers)).to.emit(this.mock, 'ERC7913SignersRemoved'); + const signersArrayAfter = await this.mock.signers().then(s => s.map(ethers.getAddress)); + expect(signersArrayAfter.length).to.equal(signersArrayBefore.length - 1); + expect(signersArrayAfter).to.not.include(ethers.getAddress(signerECDSA2.address)); // Reverts removing a signer if it doesn't exist await expect(this.mock.$_removeSigners(signers)) .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner') - .withArgs(...signers); + .withArgs(...signers.map(s => s.toLowerCase())); }); it('can change threshold', async function () { @@ -184,10 +183,10 @@ describe('AccountMultiSigner', function () { }); it('can read signers and threshold', async function () { - const signersArray = await this.mock.signers(); + const signersArray = await this.mock.signers().then(s => s.map(ethers.getAddress)); // Checksum expect(signersArray).to.have.lengthOf(2); - expect(signersArray).to.include(encodeECDSASigner(signerECDSA1.address)); - expect(signersArray).to.include(encodeECDSASigner(signerECDSA2.address)); + expect(signersArray).to.include(signerECDSA1.address); + expect(signersArray).to.include(signerECDSA2.address); const currentThreshold = await this.mock.threshold(); expect(currentThreshold).to.equal(1); @@ -199,10 +198,7 @@ describe('AccountMultiSigner', function () { beforeEach(async function () { // Set up mock with authorized signers - this.mock = await this.makeMock( - [encodeECDSASigner(signerECDSA1.address), encodeECDSASigner(signerECDSA2.address)], - 1, - ); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1); await this.mock.deploy(); }); @@ -213,12 +209,12 @@ describe('AccountMultiSigner', function () { // Prepare signers and signatures arrays const signers = [ - encodeECDSASigner(signerECDSA1.address), - encodeECDSASigner(signerECDSA4.address), // Unauthorized signer + signerECDSA1.address, + signerECDSA4.address, // Unauthorized signer ].sort((a, b) => (ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1)); const signatures = signers.map(signer => { - if (signer === encodeECDSASigner(signerECDSA1.address)) return authorizedSignature; + if (signer === signerECDSA1.address) return authorizedSignature; return unauthorizedSignature; }); @@ -235,12 +231,12 @@ describe('AccountMultiSigner', function () { const invalidSignature = await signerECDSA2.signMessage(ethers.toUtf8Bytes('Different message')); // Wrong message // Prepare signers and signatures arrays - const signers = [encodeECDSASigner(signerECDSA1.address), encodeECDSASigner(signerECDSA2.address)].sort((a, b) => + const signers = [signerECDSA1.address, signerECDSA2.address].sort((a, b) => ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1, ); const signatures = signers.map(signer => { - if (signer === encodeECDSASigner(signerECDSA1.address)) return validSignature; + if (signer === signerECDSA1.address) return validSignature; return invalidSignature; }); diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index e46ddcf3..4b486e4f 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -134,34 +134,24 @@ describe('AccountMultiSignerWeighted', function () { }); describe('Weight management', function () { - const encodeECDSASigner = address => ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], [address]); - beforeEach(async function () { const weights = [1, 2, 3]; this.signer = new NonNativeSigner( new MultiERC7913SigningKey([signerECDSA1, signerECDSA2, signerECDSA3], weights), ); - this.mock = await this.makeMock( - [ - encodeECDSASigner(signerECDSA1.address), - encodeECDSASigner(signerECDSA2.address), - encodeECDSASigner(signerECDSA3.address), - ], - weights, - 4, - ); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address, signerECDSA3.address], weights, 4); await this.mock.deploy(); }); it('verifies signerId function returns keccak256(signer)', async function () { - const signer = encodeECDSASigner(signerECDSA1.address); + const signer = signerECDSA1.address; await expect(this.mock.signerId(signer)).to.eventually.equal(ethers.keccak256(signer)); }); it('can get signer weights', async function () { - const signer1 = encodeECDSASigner(signerECDSA1.address); - const signer2 = encodeECDSASigner(signerECDSA2.address); - const signer3 = encodeECDSASigner(signerECDSA3.address); + const signer1 = signerECDSA1.address; + const signer2 = signerECDSA2.address; + const signer3 = signerECDSA3.address; await expect(this.mock.signerWeight(signer1)).to.eventually.equal(1); await expect(this.mock.signerWeight(signer2)).to.eventually.equal(2); @@ -169,9 +159,9 @@ describe('AccountMultiSignerWeighted', function () { }); it('can update signer weights', async function () { - const signer1 = encodeECDSASigner(signerECDSA1.address); - const signer2 = encodeECDSASigner(signerECDSA2.address); - const signer3 = encodeECDSASigner(signerECDSA3.address); + const signer1 = signerECDSA1.address; + const signer2 = signerECDSA2.address; + const signer3 = signerECDSA3.address; // Successfully updates weights and emits event await expect(this.mock.$_setSignerWeights([signer1, signer2], [5, 5])) @@ -186,26 +176,26 @@ describe('AccountMultiSignerWeighted', function () { }); it('cannot set weight to non-existent signer', async function () { - const randomSigner = encodeECDSASigner(ethers.Wallet.createRandom().address); + const randomSigner = ethers.Wallet.createRandom().address; // Reverts when setting weight for non-existent signer await expect(this.mock.$_setSignerWeights([randomSigner], [1])) .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner') - .withArgs(randomSigner); + .withArgs(randomSigner.toLowerCase()); }); it('cannot set weight to 0', async function () { - const signer1 = encodeECDSASigner(signerECDSA1.address); + const signer1 = signerECDSA1.address; // Reverts when setting weight to 0 await expect(this.mock.$_setSignerWeights([signer1], [0])) .to.be.revertedWithCustomError(this.mock, 'MultiERC7913WeightedInvalidWeight') - .withArgs(signer1, 0); + .withArgs(signer1.toLowerCase(), 0); }); it('requires signers and weights arrays to have same length', async function () { - const signer1 = encodeECDSASigner(signerECDSA1.address); - const signer2 = encodeECDSASigner(signerECDSA2.address); + const signer1 = signerECDSA1.address; + const signer2 = signerECDSA2.address; // Reverts when arrays have different lengths await expect(this.mock.$_setSignerWeights([signer1, signer2], [1])).to.be.revertedWithCustomError( @@ -215,9 +205,9 @@ describe('AccountMultiSignerWeighted', function () { }); it('validates threshold is reachable when updating weights', async function () { - const signer1 = encodeECDSASigner(signerECDSA1.address); - const signer2 = encodeECDSASigner(signerECDSA2.address); - const signer3 = encodeECDSASigner(signerECDSA3.address); + const signer1 = signerECDSA1.address; + const signer2 = signerECDSA2.address; + const signer3 = signerECDSA3.address; // First, lower the weights so the sum is exactly 6 (just enough for threshold=6) await expect(this.mock.$_setSignerWeights([signer1, signer2, signer3], [1, 2, 3])).to.emit( @@ -236,7 +226,7 @@ describe('AccountMultiSignerWeighted', function () { }); it('reports default weight of 1 for signers without explicit weight', async function () { - const signer4 = encodeECDSASigner(signerECDSA4.address); + const signer4 = signerECDSA4.address; // Add a new signer without setting weight await this.mock.$_addSigners([signer4]); @@ -250,7 +240,7 @@ describe('AccountMultiSignerWeighted', function () { }); it('updates total weight when adding and removing signers', async function () { - const signer4 = encodeECDSASigner(signerECDSA4.address); + const signer4 = signerECDSA4.address; // Add a new signer - should increase total weight by default weight (1) await this.mock.$_addSigners([signer4]); From 33ea2c78db1636e132293a32e275f891c4dc3469 Mon Sep 17 00:00:00 2001 From: Gonzalo Othacehe Date: Thu, 24 Apr 2025 15:51:21 +0200 Subject: [PATCH 21/90] fix unreachable threshold bug, minor variable renamings --- .../utils/cryptography/MultiSignerERC7913.sol | 10 +++---- .../MultiSignerERC7913Weighted.sol | 28 +++++++++--------- test/account/AccountMultiSigner.test.js | 10 +++++++ .../AccountMultiSignerWeighted.test.js | 29 +++++++++++++++---- 4 files changed, 53 insertions(+), 24 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index ed3d29a9..79f29248 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -119,17 +119,17 @@ abstract contract MultiSignerERC7913 is AbstractSigner { } /// @dev Sets the signatures `threshold` required to approve a multisignature operation. Internal version without access control. - function _setThreshold(uint256 threshold_) internal virtual { - _threshold = threshold_; + function _setThreshold(uint256 newThreshold) internal virtual { + _threshold = newThreshold; _validateReachableThreshold(); - emit ThresholdSet(threshold_); + emit ThresholdSet(newThreshold); } /// @dev Validates the current threshold is reachable. function _validateReachableThreshold() internal view virtual { uint256 totalSigners = _signers().length(); - uint256 minThreshold = threshold(); - require(totalSigners >= minThreshold, MultiERC7913UnreachableThreshold(totalSigners, minThreshold)); + uint256 currentThreshold = threshold(); + require(totalSigners >= currentThreshold, MultiERC7913UnreachableThreshold(totalSigners, currentThreshold)); } /** diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 5bbb5990..d294edb1 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -50,7 +50,7 @@ import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.s abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; - // Invariant: sum(weights) == threshold + // Invariant: sum(weights) >= threshold uint256 private _totalWeight; // Mapping from signer ID to weight @@ -93,19 +93,19 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { * - Each signer must exist in the set of authorized signers. Reverts with {MultiSignerERC7913NonexistentSigner} if not. * - Each weight must be greater than 0. Reverts with {MultiERC7913WeightedInvalidWeight} if not. */ - function _setSignerWeights(bytes[] memory signers, uint256[] memory weights) internal virtual { - require(signers.length == weights.length, MultiERC7913WeightedMismatchedLength()); + function _setSignerWeights(bytes[] memory signers, uint256[] memory newWeights) internal virtual { + require(signers.length == newWeights.length, MultiERC7913WeightedMismatchedLength()); for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; - uint256 weight = weights[i]; + uint256 newWeight = newWeights[i]; require(_signers().contains(signer), MultiSignerERC7913NonexistentSigner(signer)); - require(weight > 0, MultiERC7913WeightedInvalidWeight(signer, weight)); + require(newWeight > 0, MultiERC7913WeightedInvalidWeight(signer, newWeight)); uint256 oldWeight = _signerWeight(signer); - _weights[signerId(signer)] = weight; - _totalWeight = _totalWeight - oldWeight + weight; - emit ERC7913SignerWeightChanged(signer, weight); + _weights[signerId(signer)] = newWeight; + _totalWeight += newWeight - oldWeight; + emit ERC7913SignerWeightChanged(signer, newWeight); } _validateReachableThreshold(); @@ -118,15 +118,15 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { } /// @inheritdoc MultiSignerERC7913 - function _removeSigners(bytes[] memory signers) internal virtual override { - uint256 removedWeight = _weightSigners(signers); - super._removeSigners(signers); + function _removeSigners(bytes[] memory oldSigners) internal virtual override { + uint256 removedWeight = _weightSigners(oldSigners); _totalWeight -= removedWeight; // Clean up weights for removed signers - for (uint256 i = 0; i < signers.length; i++) { - delete _weights[signerId(signers[i])]; - emit ERC7913SignerWeightChanged(signers[i], 0); + for (uint256 i = 0; i < oldSigners.length; i++) { + delete _weights[signerId(oldSigners[i])]; + emit ERC7913SignerWeightChanged(oldSigners[i], 0); } + super._removeSigners(oldSigners); } /** diff --git a/test/account/AccountMultiSigner.test.js b/test/account/AccountMultiSigner.test.js index a3a070be..e349a1a6 100644 --- a/test/account/AccountMultiSigner.test.js +++ b/test/account/AccountMultiSigner.test.js @@ -129,6 +129,11 @@ describe('AccountMultiSigner', function () { await this.mock.deploy(); }); + it('verifies signerId function returns keccak256(signer)', async function () { + const signer = signerECDSA1.address; + await expect(this.mock.signerId(signer)).to.eventually.equal(ethers.keccak256(signer)); + }); + it('can add signers', async function () { const signers = [ signerECDSA3.address, // ECDSA Signer @@ -161,6 +166,11 @@ describe('AccountMultiSigner', function () { await expect(this.mock.$_removeSigners(signers)) .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner') .withArgs(...signers.map(s => s.toLowerCase())); + + // Reverts if removing a signer makes the threshold unreachable + await expect(this.mock.$_removeSigners([signerECDSA1.address])) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913UnreachableThreshold') + .withArgs(0, 1); }); it('can change threshold', async function () { diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index 4b486e4f..f6b97af3 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -143,11 +143,6 @@ describe('AccountMultiSignerWeighted', function () { await this.mock.deploy(); }); - it('verifies signerId function returns keccak256(signer)', async function () { - const signer = signerECDSA1.address; - await expect(this.mock.signerId(signer)).to.eventually.equal(ethers.keccak256(signer)); - }); - it('can get signer weights', async function () { const signer1 = signerECDSA1.address; const signer2 = signerECDSA2.address; @@ -223,6 +218,11 @@ describe('AccountMultiSignerWeighted', function () { this.mock, 'MultiERC7913UnreachableThreshold', ); + + // Also try to increase threshold to be larger than the total weight + await expect(this.mock.$_setThreshold(7)) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913UnreachableThreshold') + .withArgs(6, 7); }); it('reports default weight of 1 for signers without explicit weight', async function () { @@ -235,6 +235,11 @@ describe('AccountMultiSignerWeighted', function () { await expect(this.mock.signerWeight(signer4)).to.eventually.equal(1); }); + it('reports weight of 0 for invalid signers', async function () { + const randomSigner = ethers.Wallet.createRandom().address; + await expect(this.mock.signerWeight(randomSigner)).to.eventually.equal(0); + }); + it('can get total weight of all signers', async function () { await expect(this.mock.totalWeight()).to.eventually.equal(6); // 1 + 2 + 3 }); @@ -254,5 +259,19 @@ describe('AccountMultiSignerWeighted', function () { await this.mock.$_removeSigners([signer4]); await expect(this.mock.totalWeight()).to.eventually.equal(6); // 11 - 5 }); + + it('removing signers should not make threshold unreachable', async function () { + // current threshold = 4, totalWeight = 6 + const signer1 = signerECDSA1.address; // weight 1 + const signer3 = signerECDSA3.address; // weight 3 + + // Removing signer3 should let threshold unreachable and revert. (new totalWeight = 3, threshold = 4) + await expect(this.mock.$_removeSigners([signer3])) + .to.be.revertedWithCustomError(this.mock, 'MultiERC7913UnreachableThreshold') + .withArgs(3, 4); + + // Removing signer1 should not revert (new totalWeight = 5, threshold = 4) + await expect(this.mock.$_removeSigners([signer1])).to.not.be.reverted; + }); }); }); From b41d3f3b454ebc35f3fa6ebc119c49c1444b518e Mon Sep 17 00:00:00 2001 From: Gonzalo Othacehe Date: Thu, 24 Apr 2025 16:11:16 +0200 Subject: [PATCH 22/90] fixed just introduced arithmetic underflow --- contracts/utils/cryptography/MultiSignerERC7913Weighted.sol | 2 +- test/account/AccountMultiSignerWeighted.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index d294edb1..acbb3f1e 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -104,7 +104,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { uint256 oldWeight = _signerWeight(signer); _weights[signerId(signer)] = newWeight; - _totalWeight += newWeight - oldWeight; + _totalWeight = _totalWeight + newWeight - oldWeight; emit ERC7913SignerWeightChanged(signer, newWeight); } diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index f6b97af3..a7383228 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -219,7 +219,7 @@ describe('AccountMultiSignerWeighted', function () { 'MultiERC7913UnreachableThreshold', ); - // Also try to increase threshold to be larger than the total weight + // Try to increase threshold to be larger than the total weight await expect(this.mock.$_setThreshold(7)) .to.be.revertedWithCustomError(this.mock, 'MultiERC7913UnreachableThreshold') .withArgs(6, 7); From e8b1557147e2b0621015decc9c56b9a7b0323ad4 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 24 Apr 2025 16:25:41 -0600 Subject: [PATCH 23/90] Improve consistency of error and event naming --- .../utils/cryptography/MultiSignerERC7913.sol | 15 +++++++++------ .../cryptography/MultiSignerERC7913Weighted.sol | 14 +++++++------- test/account/AccountMultiSigner.test.js | 8 ++++---- test/account/AccountMultiSignerWeighted.test.js | 12 ++++++------ 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index 79f29248..7b627696 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -55,7 +55,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { event ERC7913SignersRemoved(bytes[] indexed signers); /// @dev Emitted when the threshold is updated. - event ThresholdSet(uint256 threshold); + event ERC7913ThresholdSet(uint256 threshold); /// @dev The `signer` already exists. error MultiSignerERC7913AlreadyExists(bytes signer); @@ -64,10 +64,10 @@ abstract contract MultiSignerERC7913 is AbstractSigner { error MultiSignerERC7913NonexistentSigner(bytes signer); /// @dev The `signer` is less than 20 bytes long. - error MultiERC7913InvalidSigner(bytes signer); + error MultiSignerERC7913InvalidSigner(bytes signer); /// @dev The `threshold` is unreachable given the number of `signers`. - error MultiERC7913UnreachableThreshold(uint256 signers, uint256 threshold); + error MultiSignerERC7913UnreachableThreshold(uint256 signers, uint256 threshold); EnumerableSetExtended.BytesSet private _signersSet; uint256 private _threshold; @@ -102,7 +102,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { function _addSigners(bytes[] memory newSigners) internal virtual { for (uint256 i = 0; i < newSigners.length; i++) { bytes memory signer = newSigners[i]; - require(signer.length >= 20, MultiERC7913InvalidSigner(signer)); + require(signer.length >= 20, MultiSignerERC7913InvalidSigner(signer)); require(_signersSet.add(signer), MultiSignerERC7913AlreadyExists(signer)); } emit ERC7913SignersAdded(newSigners); @@ -122,14 +122,17 @@ abstract contract MultiSignerERC7913 is AbstractSigner { function _setThreshold(uint256 newThreshold) internal virtual { _threshold = newThreshold; _validateReachableThreshold(); - emit ThresholdSet(newThreshold); + emit ERC7913ThresholdSet(newThreshold); } /// @dev Validates the current threshold is reachable. function _validateReachableThreshold() internal view virtual { uint256 totalSigners = _signers().length(); uint256 currentThreshold = threshold(); - require(totalSigners >= currentThreshold, MultiERC7913UnreachableThreshold(totalSigners, currentThreshold)); + require( + totalSigners >= currentThreshold, + MultiSignerERC7913UnreachableThreshold(totalSigners, currentThreshold) + ); } /** diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index acbb3f1e..a5bb0822 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -60,10 +60,10 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { event ERC7913SignerWeightChanged(bytes indexed signer, uint256 weight); /// @dev Thrown when a signer's weight is invalid. - error MultiERC7913WeightedInvalidWeight(bytes signer, uint256 weight); + error MultiSignerERC7913WeightedInvalidWeight(bytes signer, uint256 weight); /// @dev Thrown when the threshold is unreachable. - error MultiERC7913WeightedMismatchedLength(); + error MultiSignerERC7913WeightedMismatchedLength(); /// @dev Gets the weight of a signer. Returns 0 if the signer is not authorized. function signerWeight(bytes memory signer) public view virtual returns (uint256) { @@ -89,18 +89,18 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { * * Requirements: * - * - `signers` and `weights` arrays must have the same length. Reverts with {MultiERC7913WeightedMismatchedLength} on mismatch. + * - `signers` and `weights` arrays must have the same length. Reverts with {MultiSignerERC7913WeightedMismatchedLength} on mismatch. * - Each signer must exist in the set of authorized signers. Reverts with {MultiSignerERC7913NonexistentSigner} if not. - * - Each weight must be greater than 0. Reverts with {MultiERC7913WeightedInvalidWeight} if not. + * - Each weight must be greater than 0. Reverts with {MultiSignerERC7913WeightedInvalidWeight} if not. */ function _setSignerWeights(bytes[] memory signers, uint256[] memory newWeights) internal virtual { - require(signers.length == newWeights.length, MultiERC7913WeightedMismatchedLength()); + require(signers.length == newWeights.length, MultiSignerERC7913WeightedMismatchedLength()); for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; uint256 newWeight = newWeights[i]; require(_signers().contains(signer), MultiSignerERC7913NonexistentSigner(signer)); - require(newWeight > 0, MultiERC7913WeightedInvalidWeight(signer, newWeight)); + require(newWeight > 0, MultiSignerERC7913WeightedInvalidWeight(signer, newWeight)); uint256 oldWeight = _signerWeight(signer); _weights[signerId(signer)] = newWeight; @@ -139,7 +139,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { */ function _validateReachableThreshold() internal view virtual override { uint256 weight = totalWeight(); - require(weight >= threshold(), MultiERC7913UnreachableThreshold(weight, threshold())); + require(weight >= threshold(), MultiSignerERC7913UnreachableThreshold(weight, threshold())); } /** diff --git a/test/account/AccountMultiSigner.test.js b/test/account/AccountMultiSigner.test.js index e349a1a6..57be1603 100644 --- a/test/account/AccountMultiSigner.test.js +++ b/test/account/AccountMultiSigner.test.js @@ -169,18 +169,18 @@ describe('AccountMultiSigner', function () { // Reverts if removing a signer makes the threshold unreachable await expect(this.mock.$_removeSigners([signerECDSA1.address])) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913UnreachableThreshold') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913UnreachableThreshold') .withArgs(0, 1); }); it('can change threshold', async function () { // Reachable threshold is set - await expect(this.mock.$_setThreshold(2)).to.emit(this.mock, 'ThresholdSet'); + await expect(this.mock.$_setThreshold(2)).to.emit(this.mock, 'ERC7913ThresholdSet'); // Unreachable threshold reverts await expect(this.mock.$_setThreshold(3)).to.revertedWithCustomError( this.mock, - 'MultiERC7913UnreachableThreshold', + 'MultiSignerERC7913UnreachableThreshold', ); }); @@ -188,7 +188,7 @@ describe('AccountMultiSigner', function () { const invalidSigner = '0x123456'; // Too short await expect(this.mock.$_addSigners([invalidSigner])) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913InvalidSigner') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913InvalidSigner') .withArgs(invalidSigner); }); diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index a7383228..eb809763 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -184,7 +184,7 @@ describe('AccountMultiSignerWeighted', function () { // Reverts when setting weight to 0 await expect(this.mock.$_setSignerWeights([signer1], [0])) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913WeightedInvalidWeight') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913WeightedInvalidWeight') .withArgs(signer1.toLowerCase(), 0); }); @@ -195,7 +195,7 @@ describe('AccountMultiSignerWeighted', function () { // Reverts when arrays have different lengths await expect(this.mock.$_setSignerWeights([signer1, signer2], [1])).to.be.revertedWithCustomError( this.mock, - 'MultiERC7913WeightedMismatchedLength', + 'MultiSignerERC7913WeightedMismatchedLength', ); }); @@ -211,17 +211,17 @@ describe('AccountMultiSignerWeighted', function () { ); // Increase threshold to 6 - await expect(this.mock.$_setThreshold(6)).to.emit(this.mock, 'ThresholdSet').withArgs(6); + await expect(this.mock.$_setThreshold(6)).to.emit(this.mock, 'ERC7913ThresholdSet').withArgs(6); // Now try to lower weights so their sum is less than the threshold await expect(this.mock.$_setSignerWeights([signer1, signer2, signer3], [1, 1, 1])).to.be.revertedWithCustomError( this.mock, - 'MultiERC7913UnreachableThreshold', + 'MultiSignerERC7913UnreachableThreshold', ); // Try to increase threshold to be larger than the total weight await expect(this.mock.$_setThreshold(7)) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913UnreachableThreshold') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913UnreachableThreshold') .withArgs(6, 7); }); @@ -267,7 +267,7 @@ describe('AccountMultiSignerWeighted', function () { // Removing signer3 should let threshold unreachable and revert. (new totalWeight = 3, threshold = 4) await expect(this.mock.$_removeSigners([signer3])) - .to.be.revertedWithCustomError(this.mock, 'MultiERC7913UnreachableThreshold') + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913UnreachableThreshold') .withArgs(3, 4); // Removing signer1 should not revert (new totalWeight = 5, threshold = 4) From cb22d0f25f370027927e5595218b607f4984eb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Thu, 24 Apr 2025 16:32:00 -0600 Subject: [PATCH 24/90] Update contracts/utils/cryptography/MultiSignerERC7913Weighted.sol --- contracts/utils/cryptography/MultiSignerERC7913Weighted.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index a5bb0822..39af8383 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -145,7 +145,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { /** * @dev Overrides the threshold validation to use signer weights. * - * NOTE: This function intentionally does not call `super._validateReachableThreshold` because the base implementation + * NOTE: This function intentionally does not call `super. _validateThreshold` 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. From 8a424a321c66b37ff63ce5f615b498cc8a008da2 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 24 Apr 2025 16:37:32 -0600 Subject: [PATCH 25/90] Add extra isSigner function --- contracts/utils/cryptography/MultiSignerERC7913.sol | 11 ++++++----- .../utils/cryptography/MultiSignerERC7913Weighted.sol | 4 ++-- test/account/AccountMultiSigner.test.js | 10 ++++++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index 7b627696..a5940f0e 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -88,6 +88,11 @@ abstract contract MultiSignerERC7913 is AbstractSigner { return _signersSet.values(); } + /// @dev Returns whether the `signer` is an authorized signer. + function isSigner(bytes memory signer) public view virtual returns (bool) { + return _signers().contains(signer); + } + /// @dev Returns the minimum number of signers required to approve a multisignature operation. function threshold() public view virtual returns (uint256) { return _threshold; @@ -198,11 +203,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { // Signers must ordered by id to ensure no duplicates bytes memory signer = signingSigners[i]; bytes32 id = signerId(signer); - if ( - currentSignerId >= id || - !_signers().contains(signer) || - !signer.isValidSignatureNow(hash, signatures[i]) - ) { + if (currentSignerId >= id || !isSigner(signer) || !signer.isValidSignatureNow(hash, signatures[i])) { return false; } diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 39af8383..148b4c8b 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -67,7 +67,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { /// @dev Gets the weight of a signer. Returns 0 if the signer is not authorized. function signerWeight(bytes memory signer) public view virtual returns (uint256) { - return Math.ternary(_signers().contains(signer), _signerWeight(signer), 0); + return Math.ternary(isSigner(signer), _signerWeight(signer), 0); } /// @dev Gets the total weight of all signers. @@ -99,7 +99,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; uint256 newWeight = newWeights[i]; - require(_signers().contains(signer), MultiSignerERC7913NonexistentSigner(signer)); + require(isSigner(signer), MultiSignerERC7913NonexistentSigner(signer)); require(newWeight > 0, MultiSignerERC7913WeightedInvalidWeight(signer, newWeight)); uint256 oldWeight = _signerWeight(signer); diff --git a/test/account/AccountMultiSigner.test.js b/test/account/AccountMultiSigner.test.js index 57be1603..78142716 100644 --- a/test/account/AccountMultiSigner.test.js +++ b/test/account/AccountMultiSigner.test.js @@ -201,6 +201,16 @@ describe('AccountMultiSigner', function () { const currentThreshold = await this.mock.threshold(); expect(currentThreshold).to.equal(1); }); + + it('checks if an address is a signer', async function () { + // Should return true for authorized signers + await expect(this.mock.isSigner(signerECDSA1.address)).to.eventually.be.true; + await expect(this.mock.isSigner(signerECDSA2.address)).to.eventually.be.true; + + // Should return false for unauthorized signers + await expect(this.mock.isSigner(signerECDSA3.address)).to.eventually.be.false; + await expect(this.mock.isSigner(signerECDSA4.address)).to.eventually.be.false; + }); }); describe('Signature validation', function () { From a8372f6af223fa6b6452c78523c5be8a39c610ca Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 24 Apr 2025 16:50:33 -0600 Subject: [PATCH 26/90] Pack threshold and totalWeight --- contracts/utils/cryptography/MultiSignerERC7913.sol | 6 ++++-- .../utils/cryptography/MultiSignerERC7913Weighted.sol | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index a5940f0e..c31fe1fc 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -6,6 +6,7 @@ import {AbstractSigner} from "./AbstractSigner.sol"; import {ERC7913Utils} from "./ERC7913Utils.sol"; import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; import {Calldata} from "@openzeppelin/contracts/utils/Calldata.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; /** * @dev Implementation of {AbstractSigner} using multiple ERC-7913 signers with a threshold-based @@ -47,6 +48,7 @@ import {Calldata} from "@openzeppelin/contracts/utils/Calldata.sol"; abstract contract MultiSignerERC7913 is AbstractSigner { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; using ERC7913Utils for bytes; + using SafeCast for uint256; /// @dev Emitted when signers are added. event ERC7913SignersAdded(bytes[] indexed signers); @@ -70,7 +72,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { error MultiSignerERC7913UnreachableThreshold(uint256 signers, uint256 threshold); EnumerableSetExtended.BytesSet private _signersSet; - uint256 private _threshold; + uint128 private _threshold; /// @dev Returns the internal id of the `signer`. function signerId(bytes memory signer) public view virtual returns (bytes32) { @@ -125,7 +127,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { /// @dev Sets the signatures `threshold` required to approve a multisignature operation. Internal version without access control. function _setThreshold(uint256 newThreshold) internal virtual { - _threshold = newThreshold; + _threshold = newThreshold.toUint128(); _validateReachableThreshold(); emit ERC7913ThresholdSet(newThreshold); } diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 148b4c8b..59297675 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {MultiSignerERC7913} from "./MultiSignerERC7913.sol"; import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; @@ -49,9 +50,10 @@ import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.s */ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + using SafeCast for uint256; // Invariant: sum(weights) >= threshold - uint256 private _totalWeight; + uint128 private _totalWeight; // Mapping from signer ID to weight mapping(bytes32 signedId => uint256) private _weights; @@ -104,7 +106,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { uint256 oldWeight = _signerWeight(signer); _weights[signerId(signer)] = newWeight; - _totalWeight = _totalWeight + newWeight - oldWeight; + _totalWeight = (_totalWeight + newWeight - oldWeight).toUint128(); emit ERC7913SignerWeightChanged(signer, newWeight); } @@ -114,13 +116,13 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { /// @inheritdoc MultiSignerERC7913 function _addSigners(bytes[] memory newSigners) internal virtual override { super._addSigners(newSigners); - _totalWeight += newSigners.length; // Each new signer has a default weight of 1 + _totalWeight += newSigners.length.toUint128(); // Each new signer has a default weight of 1 } /// @inheritdoc MultiSignerERC7913 function _removeSigners(bytes[] memory oldSigners) internal virtual override { uint256 removedWeight = _weightSigners(oldSigners); - _totalWeight -= removedWeight; + _totalWeight -= removedWeight.toUint128(); // Clean up weights for removed signers for (uint256 i = 0; i < oldSigners.length; i++) { delete _weights[signerId(oldSigners[i])]; From 30bb001d64c25a3fdb4bf98a2476b207b17fcff2 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 24 Apr 2025 16:54:26 -0600 Subject: [PATCH 27/90] Update docs --- docs/modules/ROOT/pages/multisig-account.adoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/modules/ROOT/pages/multisig-account.adoc b/docs/modules/ROOT/pages/multisig-account.adoc index d4ca8ef6..8c78773f 100644 --- a/docs/modules/ROOT/pages/multisig-account.adoc +++ b/docs/modules/ROOT/pages/multisig-account.adoc @@ -48,6 +48,8 @@ The `MultiSignerERC7913` contract provides several key features for managing mul NOTE: `MultiSignerERC7913` safeguards to ensure that the threshold remains achievable based on the current number of active signers, preventing situations where operations could become impossible to execute. +The contract also provides the public `isSigner(bytes memory signer)` function to check if a given signer is authorized, which is useful when validating signatures or implementing customized access control logic. + === MultiSignerERC7913Weighted For more sophisticated governance structures, the xref:api:utils.adoc#MultiSignerERC7913Weighted[`MultiSignerERC7913Weighted`] contract extends `MultiSignerERC7913` by assigning different weights to each signer. From be2e94f97951651e86eb94072c10739f08913bee Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 25 Apr 2025 00:57:13 -0600 Subject: [PATCH 28/90] Updates and moar test --- .../MultiSignerERC7913Weighted.sol | 9 ++-- .../AccountMultiSignerWeighted.test.js | 50 ++++++++++++++++--- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 59297675..95abadc2 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -74,7 +74,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { /// @dev Gets the total weight of all signers. function totalWeight() public view virtual returns (uint256) { - return _totalWeight; + return Math.max(_totalWeight, _signers().length()); } /** @@ -97,6 +97,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { */ function _setSignerWeights(bytes[] memory signers, uint256[] memory newWeights) internal virtual { require(signers.length == newWeights.length, MultiSignerERC7913WeightedMismatchedLength()); + uint128 cachedTotalWeight = _totalWeight; for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; @@ -106,10 +107,11 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { uint256 oldWeight = _signerWeight(signer); _weights[signerId(signer)] = newWeight; - _totalWeight = (_totalWeight + newWeight - oldWeight).toUint128(); + cachedTotalWeight = (cachedTotalWeight + newWeight - oldWeight).toUint128(); emit ERC7913SignerWeightChanged(signer, newWeight); } + _totalWeight = cachedTotalWeight; _validateReachableThreshold(); } @@ -141,7 +143,8 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { */ function _validateReachableThreshold() internal view virtual override { uint256 weight = totalWeight(); - require(weight >= threshold(), MultiSignerERC7913UnreachableThreshold(weight, threshold())); + uint256 currentThreshold = threshold(); + require(weight >= currentThreshold, MultiSignerERC7913UnreachableThreshold(weight, currentThreshold)); } /** diff --git a/test/account/AccountMultiSignerWeighted.test.js b/test/account/AccountMultiSignerWeighted.test.js index eb809763..aa84d5be 100644 --- a/test/account/AccountMultiSignerWeighted.test.js +++ b/test/account/AccountMultiSignerWeighted.test.js @@ -241,7 +241,42 @@ describe('AccountMultiSignerWeighted', function () { }); it('can get total weight of all signers', async function () { - await expect(this.mock.totalWeight()).to.eventually.equal(6); // 1 + 2 + 3 + await expect(this.mock.totalWeight()).to.eventually.equal(6); // max(_totalWeight, _signers.length) = max(6, 3) = 6 + }); + + it('totalWeight returns correct value when all signers have default weight of 1', async function () { + // Deploy a new mock with all signers having default weight (1) + const signers = [signerECDSA1.address, signerECDSA2.address, signerECDSA3.address]; + const defaultWeights = [1, 1, 1]; // All weights are 1 (default) + const newMock = await this.makeMock(signers, defaultWeights, 2); + await newMock.deploy(); + + // totalWeight should return max(3, 3) = 3 when all weights are default + await expect(newMock.totalWeight()).to.eventually.equal(3); + + // Clear custom weights to ensure we're using default weights + await newMock.$_setSignerWeights(signers, [1, 1, 1]); + + // totalWeight should still be max(3, 3) = 3 + await expect(newMock.totalWeight()).to.eventually.equal(3); + }); + + it('_setSignerWeights correctly handles default weights when updating', async function () { + const signer1 = signerECDSA1.address; + + // Current weights are [1, 2, 3] + + // Set weight for signer1 from 1 (default) to 5 + await this.mock.$_setSignerWeights([signer1], [5]); + + // totalWeight should be updated from max(6, 3) to max(10, 3) = 10 + await expect(this.mock.totalWeight()).to.eventually.equal(10); + + // Reset signer1 to default weight (1) + await this.mock.$_setSignerWeights([signer1], [1]); + + // totalWeight should be back to max(6, 3) = 6 + await expect(this.mock.totalWeight()).to.eventually.equal(6); }); it('updates total weight when adding and removing signers', async function () { @@ -249,28 +284,29 @@ describe('AccountMultiSignerWeighted', function () { // Add a new signer - should increase total weight by default weight (1) await this.mock.$_addSigners([signer4]); - await expect(this.mock.totalWeight()).to.eventually.equal(7); // 6 + 1 + await expect(this.mock.totalWeight()).to.eventually.equal(7); // max(7, 4) = 7 // Set weight to 5 - should increase total weight by 4 await this.mock.$_setSignerWeights([signer4], [5]); - await expect(this.mock.totalWeight()).to.eventually.equal(11); // 7 + 4 + await expect(this.mock.totalWeight()).to.eventually.equal(11); // max(11, 4) = 11 // Remove signer - should decrease total weight by current weight (5) await this.mock.$_removeSigners([signer4]); - await expect(this.mock.totalWeight()).to.eventually.equal(6); // 11 - 5 + await expect(this.mock.totalWeight()).to.eventually.equal(6); // max(6, 3) = 6 }); it('removing signers should not make threshold unreachable', async function () { - // current threshold = 4, totalWeight = 6 + // current threshold = 4, totalWeight = max(6, 3) = 6 const signer1 = signerECDSA1.address; // weight 1 const signer3 = signerECDSA3.address; // weight 3 - // Removing signer3 should let threshold unreachable and revert. (new totalWeight = 3, threshold = 4) + // After removing signer3, the threshold is unreachable because totalWeight = max(3, 2) = 3 but threshold = 4 + // This should revert await expect(this.mock.$_removeSigners([signer3])) .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913UnreachableThreshold') .withArgs(3, 4); - // Removing signer1 should not revert (new totalWeight = 5, threshold = 4) + // Removing signer1 should not revert (new totalWeight = max(5, 2) = 5, threshold = 4) await expect(this.mock.$_removeSigners([signer1])).to.not.be.reverted; }); }); From 19f29121a41a79ae7b13843ae5a52cf21fc93aca Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 25 Apr 2025 00:59:17 -0600 Subject: [PATCH 29/90] Add ERC7579 Executor modules --- .../account/modules/ERC7579BaseExecutor.sol | 281 ++++++++++++++++++ .../modules/ERC7579MultisigExecutor.sol | 275 +++++++++++++++++ .../ERC7579MultisigWeightedExecutor.sol | 201 +++++++++++++ 3 files changed, 757 insertions(+) create mode 100644 contracts/account/modules/ERC7579BaseExecutor.sol create mode 100644 contracts/account/modules/ERC7579MultisigExecutor.sol create mode 100644 contracts/account/modules/ERC7579MultisigWeightedExecutor.sol diff --git a/contracts/account/modules/ERC7579BaseExecutor.sol b/contracts/account/modules/ERC7579BaseExecutor.sol new file mode 100644 index 00000000..99978287 --- /dev/null +++ b/contracts/account/modules/ERC7579BaseExecutor.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IERC7579Module, MODULE_TYPE_EXECUTOR, IERC7579Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; +import {ERC7579Utils, Mode, CallType, ExecType, ModeSelector, ModePayload} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; + +/** + * @dev Base implementation for ERC-7579 executor modules that manages scheduling and executing + * delayed operations. This module enables time-delayed execution patterns for smart accounts. + * + * Once scheduled (see {schedule}), operations can only be executed after their specified delay + * period has elapsed (indicated during {onInstall}), creating a security window where suspicious + * operations can be monitored and potentially canceled (see {cancel}) before execution (see {execute}). + * + * Accounts can customize their delay periods with {setDelay}, Delay changes take effect after a + * transition period to prevent immediate security downgrades. + * + * IMPORTANT: This module assumes the {AccountERC7579} is the ultimate authority and does not restrict + * module uninstallation. An account can bypass the time-delay security by simply uninstalling + * the module. Consider adding safeguards in your Account implementation if uninstallation + * protection is required for your security model. + */ +abstract contract ERC7579BaseExecutor is IERC7579Module { + using Time for *; + + struct Schedule { + uint48 scheduledAt; + uint32 delay; + bool executed; + } + + mapping(address account => Time.Delay delay) private _accountDelays; + mapping(bytes32 operationId => Schedule) private _schedules; + + /// @dev Emitted when a new operation is scheduled. + event ERC7579ExecutorOperationScheduled( + address indexed account, + bytes32 indexed operationId, + Mode mode, + bytes executionCalldata, + bytes32 salt, + uint48 schedule + ); + + /// @dev Emitted when an operation is executed. + event ERC7579ExecutorOperationExecuted(address indexed account, bytes32 indexed operationId); + + /// @dev Emitted when a scheduled 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 Thrown when trying to execute an operation that is not scheduled. + error ERC7579BaseExecutorOperationNotScheduled(bytes32 operationId); + + /// @dev Thrown when trying to execute an operation before its execution time. + error ERC7579BaseExecutorOperationNotReady(bytes32 operationId, uint48 schedule); + + /// @dev Thrown when trying to schedule an operation that is already scheduled. + error ERC7579BaseExecutorOperationAlreadyScheduled(bytes32 operationId); + + /// @dev Thrown when trying to execute an operation that has already been executed. + error ERC7579BaseExecutorOperationAlreadyExecuted(bytes32 operationId); + + /// @inheritdoc IERC7579Module + function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { + return moduleTypeId == MODULE_TYPE_EXECUTOR; + } + + /** + * @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` can contain an `abi.encode(uint32(initialDelay))` value. + * The delay will be set to the maximum of this value and the minimum delay if provided. + * Otherwise, the delay will be set to the minimum delay. + */ + function onInstall(bytes calldata initData) public virtual { + uint32 minDelay = minimumDelay(); // Up to ~136 years. + uint32 delay = initData.length > 0 + ? uint32(Math.max(minDelay, abi.decode(initData, (uint32)))) // Safe downcast since both arguments are uint32 + : minDelay; + _accountDelays[msg.sender] = delay.toDelay(); + } + + /** + * @dev Cleans up account-specific state when the module is uninstalled from an account. + * + * 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, but module implementations may want to override this behavior to clear + * scheduled operations during uninstallation for their specific use cases. + */ + function onUninstall(bytes calldata) public virtual { + address account = msg.sender; + _accountDelays[account] = Time.toDelay(0); + } + + /// @dev Minimum delay for operations. Default for accounts that do not set a custom delay. + function minimumDelay() public view virtual returns (uint32) { + return 1 days; + } + + /// @dev Expiration time for operations. Defaults to `type(uint32).max` (no expiration). + function expiration() public view virtual returns (uint32) { + return type(uint32).max; + } + + /// @dev Delay for a specific account. If not set, returns the minimum delay. + function getDelay( + address account + ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { + (uint32 currentDelay, uint32 newDelay, uint48 effect) = _accountDelays[account].getFull(); + return ( + uint32(Math.max(currentDelay, minimumDelay())), // Safe downcast since both arguments are uint32 + newDelay, + effect + ); + } + + /** + * @dev Schedule for an operation. Returns default values if not set + * (i.e. `uint48(0)`, `uint32(0)`, `uint48(0)`, and `false`). + */ + function getSchedule( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public view virtual returns (uint48 scheduledAt, uint32 delay, uint48 timepoint, bool executed) { + return getSchedule(hashOperation(account, mode, executionCalldata, salt)); + } + + /// @dev Same as {getSchedule} but with the operation id. + function getSchedule( + bytes32 operationId + ) public view virtual returns (uint48 scheduledAt, uint32 delay, uint48 timepoint, bool executed) { + Schedule storage schedule_ = _schedules[operationId]; + scheduledAt = schedule_.scheduledAt; + delay = schedule_.delay; + timepoint = scheduledAt + delay; + return (scheduledAt, delay, timepoint, schedule_.executed); + } + + /// @dev Returns the operation id. + function hashOperation( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public view virtual returns (bytes32) { + return keccak256(abi.encode(account, mode, executionCalldata, salt)); + } + + /** + * @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 minimum delay, whichever is longer. This prevents immediate security downgrades. + * Can only be called by the account itself. + */ + function setDelay(uint32 newDelay) public virtual { + address account = msg.sender; + _setDelay(account, newDelay); + } + + /** + * @dev Schedules an operation to be executed after the account's delay period (see {getDelay}). + * Operations are uniquely identified by the combination of `mode`, `executionCalldata`, and `salt`. + * Can only be called by the account itself to schedule its own operations. + * + * Requirements: + * + * * Operation must not have been scheduled already. Reverts with {ERC7579BaseExecutorOperationAlreadyScheduled} otherwise. + */ + function schedule(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + _schedule(msg.sender, mode, executionCalldata, salt); + } + + /** + * @dev Executes a previously scheduled operation if its delay period has elapsed (see {getDelay}). + * Returns the result data from the executed operation. + * + * Requirements: + * + * * Operation must have been scheduled. Reverts with {ERC7579BaseExecutorOperationNotScheduled} otherwise. + * * Operation must not have been executed yet. Reverts with {ERC7579BaseExecutorOperationAlreadyExecuted} otherwise. + * * Operation must be ready for execution. Reverts with {ERC7579BaseExecutorOperationNotReady} otherwise. + * + * The operation must be scheduled and not already executed. + * + * NOTE: Anyone can trigger execution once the timepoint has been reached. + */ + function execute( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public virtual returns (bytes[] memory returnData) { + return _execute(account, mode, executionCalldata, salt); + } + + /** + * @dev Cancels a previously scheduled operation. Can only be called by the account that scheduled the operation. + * + * Requirements: + * + * * Operation must have been scheduled. Reverts with {ERC7579BaseExecutorOperationNotScheduled} otherwise. + */ + function cancel(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + _cancel(msg.sender, mode, executionCalldata, salt); + } + + /** + * @dev Internal implementation for setting an account's delay. + * + * Updates the account's delay configuration and emits an event with the + * new delay and when it will take effect. + */ + function _setDelay(address account, uint32 newDelay) internal virtual { + uint48 effect; + (_accountDelays[account], effect) = _accountDelays[account].withUpdate(newDelay, minimumDelay()); + emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); + } + + /// @dev Internal version of {schedule} that takes an `account` address as an argument. + function _schedule( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal virtual returns (bytes32 operationId, Schedule memory schedule_) { + bytes32 id = hashOperation(account, mode, executionCalldata, salt); + (uint32 delay, , ) = getDelay(account); + + uint48 timepoint = Time.timestamp() + delay; + require(timepoint == 0, ERC7579BaseExecutorOperationAlreadyScheduled(id)); + + schedule_ = Schedule(Time.timestamp(), delay, false); + _schedules[id] = schedule_; + + emit ERC7579ExecutorOperationScheduled(account, id, mode, executionCalldata, salt, timepoint); + return (id, schedule_); + } + + /// @dev Internal version of {execute}. + function _execute( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal virtual returns (bytes[] memory returnData) { + bytes32 id = hashOperation(account, mode, executionCalldata, salt); + (uint48 scheduledAt, , uint48 timepoint, bool executed) = getSchedule(id); + + require(scheduledAt != 0, ERC7579BaseExecutorOperationNotScheduled(id)); + require(!executed, ERC7579BaseExecutorOperationAlreadyExecuted(id)); + require(Time.timestamp() >= timepoint, ERC7579BaseExecutorOperationNotReady(id, timepoint)); + + _schedules[id].executed = true; // Mark the operation as executed + emit ERC7579ExecutorOperationExecuted(account, id); + return IERC7579Execution(account).executeFromExecutor(Mode.unwrap(mode), executionCalldata); + } + + /// @dev Internal version of {cancel} that takes an `account` address as an argument. + function _cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + bytes32 id = hashOperation(account, mode, executionCalldata, salt); + (uint48 scheduledAt, , , bool executed) = getSchedule(id); + + require(scheduledAt != 0, ERC7579BaseExecutorOperationNotScheduled(id)); + require(!executed, ERC7579BaseExecutorOperationAlreadyExecuted(id)); + + _schedules[id].scheduledAt = 0; + _schedules[id].delay = 0; + _schedules[id].executed = false; + emit ERC7579ExecutorOperationCanceled(account, id); + } +} diff --git a/contracts/account/modules/ERC7579MultisigExecutor.sol b/contracts/account/modules/ERC7579MultisigExecutor.sol new file mode 100644 index 00000000..8110dc6f --- /dev/null +++ b/contracts/account/modules/ERC7579MultisigExecutor.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC7579BaseExecutor} from "./ERC7579BaseExecutor.sol"; +import {ERC7913Utils} from "../../utils/cryptography/ERC7913Utils.sol"; +import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; +import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; + +/** + * @dev Implementation of {ERC7579BaseExecutor} that uses ERC-7913 signers for multisignature + * operation scheduling. + * + * This module extends the base time-delayed executor with multisignature capabilities, + * allowing an operation to be scheduled once it has been signed by a required threshold + * of authorized signers. The signers are represented using the ERC-7913 format, + * which concatenates a verifier address and a key: `verifier || key`. + * + * Operations can be scheduled using either: + * - The account itself through the standard {schedule} function + * - Or by collecting signatures from multiple authorized signers through {scheduleMultisigner} + * + * Example use case: + * + * A smart account with this module installed can schedule social recovery operations + * after obtaining approval from a set number of signers (e.g., 3-of-5 guardians), + * and then execute them after the time delay has passed. + */ +contract ERC7579MultisigExecutor is ERC7579BaseExecutor { + using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + using ERC7913Utils for bytes; + + /// @dev Emitted when signers are added. + event ERC7913SignersAdded(bytes[] indexed signers); + + /// @dev Emitted when signers are removed. + event ERC7913SignersRemoved(bytes[] indexed signers); + + /// @dev Emitted when the threshold is updated. + event ERC7913ThresholdSet(uint256 threshold); + + /// @dev The `signer` already exists. + error ERC7579MultisigExecutorAlreadyExists(bytes signer); + + /// @dev The `signer` does not exist. + error ERC7579MultisigExecutorNonexistentSigner(bytes signer); + + /// @dev The `signer` is less than 20 bytes long. + error ERC7579MultisigExecutorInvalidSigner(bytes signer); + + /// @dev The `threshold` is unreachable given the number of `signers`. + error ERC7579MultisigExecutorUnreachableThreshold(uint256 signers, uint256 threshold); + + /// @dev The signatures are invalid. + error ERC7579MultisigExecutorInvalidSignatures(); + + 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 {ERC7579BaseExecutor-onInstall}. Besides the delay setup, the `initdata` can + * include `signers` and `threshold`. + * + * The initData should be encoded as: + * `abi.encode(uint32 initialDelay, bytes[] signers, uint256 threshold)` + * + * If no signers or threshold are provided, the multisignature functionality will be + * disabled until they are added later. + */ + function onInstall(bytes calldata initData) public virtual override { + super.onInstall(initData); + + if (initData.length > 32) { + // More than just delay parameter + (, bytes[] memory signers_, uint256 threshold_) = abi.decode(initData, (uint32, 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 {ERC7579BaseExecutor-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 override { + _signersSetByAccount[msg.sender].clear(); + delete _thresholdByAccount[msg.sender]; + super.onUninstall(data); + } + + /// @dev Returns the unique identifier of the `signer`. + function signerId(bytes memory signer) public pure virtual returns (bytes32) { + return keccak256(signer); + } + + /** + * @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 _signersSetByAccount[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 _signersSetByAccount[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. + */ + 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. + */ + 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. + */ + function setThreshold(uint256 newThreshold) public virtual { + _setThreshold(msg.sender, newThreshold); + } + + /** + * @dev Schedules an operation using signatures from multiple authorized {signers}. + * The operation will be scheduled if 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. See {hashOperation} for the operation hash. + * + * NOTE: Signers should be ordered by their {signerId} to prevent duplications. + */ + function scheduleMultisigner( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt, + bytes calldata signature + ) public virtual returns (bytes32 operationId) { + bytes32 hash = hashOperation(account, mode, executionCalldata, salt); + (bytes[] memory signingSigners, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); + require( + _validateNSignatures(account, hash, signingSigners, signatures) && + _validateThreshold(account, signingSigners), + ERC7579MultisigExecutorInvalidSignatures() + ); + + // Schedule the operation + (operationId, ) = _schedule(account, mode, executionCalldata, salt); + return operationId; + } + + /// @dev Adds the `newSigners` to those allowed to sign on behalf of the account. + function _addSigners(address account, bytes[] memory newSigners) internal virtual { + EnumerableSetExtended.BytesSet storage signerSet = _signers(account); + + for (uint256 i = 0; i < newSigners.length; i++) { + bytes memory signer = newSigners[i]; + require(signer.length >= 20, ERC7579MultisigExecutorInvalidSigner(signer)); + require(signerSet.add(signer), ERC7579MultisigExecutorAlreadyExists(signer)); + } + + emit ERC7913SignersAdded(newSigners); + } + + /// @dev Removes the `oldSigners` from the authorized signers for the account. + function _removeSigners(address account, bytes[] memory oldSigners) internal virtual { + EnumerableSetExtended.BytesSet storage signerSet = _signers(account); + + for (uint256 i = 0; i < oldSigners.length; i++) { + bytes memory signer = oldSigners[i]; + require(signerSet.remove(signer), ERC7579MultisigExecutorNonexistentSigner(signer)); + } + + _validateReachableThreshold(account); + emit ERC7913SignersRemoved(oldSigners); + } + + /// @dev Sets the signatures `threshold` required to approve a multisignature operation. + function _setThreshold(address account, uint256 newThreshold) internal virtual { + _thresholdByAccount[account] = newThreshold; + _validateReachableThreshold(account); + emit ERC7913ThresholdSet(newThreshold); + } + + /// @dev Validates the current threshold is reachable with the number of {signers}. + function _validateReachableThreshold(address account) internal view virtual { + uint256 totalSigners = _signers(account).length(); + uint256 currentThreshold = threshold(account); + require( + totalSigners >= currentThreshold, + ERC7579MultisigExecutorUnreachableThreshold(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. + * + * Requirements: + * + * - The `signers` and `signatures` arrays must be of the same length. + * - The signers must be ordered by their {signerId}. + * - The number of valid signatures must meet or exceed the threshold. + */ + function _validateNSignatures( + address account, + bytes32 hash, + bytes[] memory signingSigners, + bytes[] memory signatures + ) internal view virtual returns (bool valid) { + bytes32 currentSignerId = bytes32(0); + + uint256 signersLength = signingSigners.length; + for (uint256 i = 0; i < signersLength; i++) { + // Signers must be ordered by id to ensure no duplicates + bytes memory signer = signingSigners[i]; + bytes32 id = signerId(signer); + + if ( + currentSignerId >= id || !isSigner(account, signer) || !signer.isValidSignatureNow(hash, signatures[i]) + ) { + return false; + } + + currentSignerId = id; + } + + return true; + } + + /** + * @dev Validates that the number of signers meets the {threshold} requirement. + * Assumes the signers were already validated. See {_validateNSignatures} 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/ERC7579MultisigWeightedExecutor.sol b/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol new file mode 100644 index 00000000..8082ecc1 --- /dev/null +++ b/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC7579MultisigExecutor} from "./ERC7579MultisigExecutor.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; + +/** + * @dev Extension of {ERC7579MultisigExecutor} that supports weighted signatures. + * + * This module extends the multisignature executor 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). + */ +contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { + using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + + // Mapping from account => signerId => weight + mapping(address account => mapping(bytes32 signerId => uint256 weight)) private _weightsByAccount; + + // Invariant: sum(weights(account)) >= threshold(account) + mapping(address account => uint256 totalWeight) private _totalWeightByAccount; + + /// @dev Emitted when a signer's weight is changed. + event ERC7913SignerWeightChanged(address indexed account, bytes indexed signer, uint256 weight); + + /// @dev Thrown when a signer's weight is invalid. + error ERC7579MultisigExecutorInvalidWeight(bytes signer, uint256 weight); + + /// @dev Thrown when the arrays lengths don't match. + error ERC7579MultisigExecutorMismatchedLength(); + + /** + * @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(uint32 initialDelay, bytes[] signers, uint256 threshold, uint256[] weights)` + * + * If weights are not provided but signers are, all signers default to weight 1. + */ + function onInstall(bytes calldata initData) public virtual override { + super.onInstall(initData); + + (, bytes[] memory signers, uint256 threshold, uint256[] memory weights) = abi.decode( + initData, + (uint32, bytes[], uint256, uint256[]) + ); + + _addSigners(msg.sender, signers); + _setSignerWeights(msg.sender, signers, weights); + _setThreshold(msg.sender, threshold); + } + + /** + * @dev Cleans up module's configuration when uninstalled from an account. + * Clears all signers, weights, and total weights. + * + * See {ERC7579MultisigExecutor-onUninstall}. + */ + function onUninstall(bytes calldata data) public virtual override { + address account = msg.sender; + + bytes[] memory allSigners = signers(account); + for (uint256 i = 0; i < allSigners.length; i++) { + delete _weightsByAccount[account][signerId(allSigners[i])]; + } + delete _totalWeightByAccount[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 Math.max(_totalWeightByAccount[account], _signers(account).length()); + } + + /** + * @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(_weightsByAccount[account][signerId(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. + * - Each signer must exist in the set of authorized signers. + * - Each weight must be greater than 0. + */ + function _setSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) internal virtual { + require(signers.length == newWeights.length, ERC7579MultisigExecutorMismatchedLength()); + + uint256 cachedTotalWeight = _totalWeightByAccount[account]; + for (uint256 i = 0; i < signers.length; i++) { + bytes memory signer = signers[i]; + uint256 newWeight = newWeights[i]; + require(isSigner(account, signer), ERC7579MultisigExecutorNonexistentSigner(signer)); + require(newWeight > 0, ERC7579MultisigExecutorInvalidWeight(signer, newWeight)); + + uint256 oldWeight = _signerWeight(account, signer); + _weightsByAccount[account][signerId(signer)] = newWeight; + cachedTotalWeight = (cachedTotalWeight + newWeight - oldWeight); + emit ERC7913SignerWeightChanged(account, signer, newWeight); + } + + _totalWeightByAccount[account] = cachedTotalWeight; + _validateReachableThreshold(account); + } + + /** + * @dev Override to add weight tracking. See {ERC7579MultisigExecutor-_addSigners}. + * Each new signer has a default weight of 1. + */ + function _addSigners(address account, bytes[] memory newSigners) internal virtual override { + super._addSigners(account, newSigners); + _totalWeightByAccount[account] += newSigners.length; // Default weight of 1 per signer + } + + /// @dev Override to handle weight tracking during removal. See {ERC7579MultisigExecutor-_removeSigners}. + function _removeSigners(address account, bytes[] memory oldSigners) internal virtual override { + uint256 removedWeight = _weightSigners(account, oldSigners); + _totalWeightByAccount[account] -= removedWeight; + + for (uint256 i = 0; i < oldSigners.length; i++) { + delete _weightsByAccount[account][signerId(oldSigners[i])]; + emit ERC7913SignerWeightChanged(account, oldSigners[i], 0); + } + + 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, ERC7579MultisigExecutorUnreachableThreshold(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; + } +} From 6cfa40721179ec0d4fadbf9b7be3cc19b341185e Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 25 Apr 2025 19:04:49 -0600 Subject: [PATCH 30/90] Simplify MultiSignerERC7913Weighted and reorder SignerERC7913 --- .../cryptography/MultiSignerERC7913Weighted.sol | 5 +---- contracts/utils/cryptography/SignerERC7913.sol | 12 ++++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 8fdea362..7cae6e9a 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -126,10 +126,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { uint256 removedWeight = _weightSigners(oldSigners); _totalWeight -= removedWeight.toUint128(); // Clean up weights for removed signers - for (uint256 i = 0; i < oldSigners.length; i++) { - delete _weights[signerId(oldSigners[i])]; - emit ERC7913SignerWeightChanged(oldSigners[i], 0); - } + _setSignerWeights(signers, new uint256[](oldSigners.length)); super._removeSigners(oldSigners); } diff --git a/contracts/utils/cryptography/SignerERC7913.sol b/contracts/utils/cryptography/SignerERC7913.sol index 3841be19..ae05d618 100644 --- a/contracts/utils/cryptography/SignerERC7913.sol +++ b/contracts/utils/cryptography/SignerERC7913.sol @@ -31,17 +31,17 @@ import {ERC7913Utils} from "./ERC7913Utils.sol"; abstract contract SignerERC7913 is AbstractSigner { bytes private _signer; - /// @dev Sets the signer (i.e. `verifier || key`) with an ERC-7913 formatted signer. - function _setSigner(bytes memory signer_) internal { - _signer = signer_; - } - /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). function signer() public view virtual returns (bytes memory) { return _signer; } - /// @dev Verifies a signature using {ERC7913Utils.isValidSignatureNow} with {signer}, `hash` and `signature`. + /// @dev Sets the signer (i.e. `verifier || key`) with an ERC-7913 formatted signer. + function _setSigner(bytes memory signer_) internal { + _signer = signer_; + } + + /// @dev Verifies a signature using {ERC7913Utils-isValidSignatureNow} with {signer}, `hash` and `signature`. function _rawSignatureValidation( bytes32 hash, bytes calldata signature From 1551e61e92e5830b9d29bbe916955ea1720f5faf Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 25 Apr 2025 19:31:39 -0600 Subject: [PATCH 31/90] Move isValidNSignatures to ERC7913Utils --- contracts/utils/cryptography/ERC7913Utils.sol | 36 +++++++++++++++++++ .../utils/cryptography/MultiSignerERC7913.sol | 14 ++------ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol index 739bb17c..39dc1720 100644 --- a/contracts/utils/cryptography/ERC7913Utils.sol +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -48,4 +48,40 @@ library ERC7913Utils { abi.decode(result, (bytes32)) == bytes32(IERC7913SignatureVerifier.verify.selector)); } } + + /** + * @dev Verifies multiple `signatures` for a given hash using a set of `signers`. + * + * The signers must be ordered by their `signerId` to ensure no duplicates and to optimize + * the verification process. The function will return `false` if the signers are not properly ordered. + * + * Requirements: + * + * * The `signatures` array must be at least the `signers` array's length. + * + * NOTE: The `signerId` function argument must be deterministic and should not manipulate + * memory state directly and should follow Solidity memory safety rules to avoid unexpected behavior. + */ + function isValidNSignaturesNow( + bytes32 hash, + bytes[] memory signers, + bytes[] memory signatures, + function(bytes memory) view returns (bytes32) signerId + ) internal view returns (bool) { + bytes32 currentSignerId = bytes32(0); + + uint256 signersLength = signers.length; + for (uint256 i = 0; i < signersLength; i++) { + bytes memory signer = signers[i]; + // Signers must ordered by id to ensure no duplicates + bytes32 id = signerId(signer); + if (currentSignerId >= id || !isValidSignatureNow(signer, hash, signatures[i])) { + return false; + } + + currentSignerId = id; + } + + return true; + } } diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index 4076365c..58b93537 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -47,7 +47,7 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; */ abstract contract MultiSignerERC7913 is AbstractSigner { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; - using ERC7913Utils for bytes; + using ERC7913Utils for *; using SafeCast for uint256; EnumerableSetExtended.BytesSet private _signersSet; @@ -198,21 +198,13 @@ abstract contract MultiSignerERC7913 is AbstractSigner { bytes[] memory signingSigners, bytes[] memory signatures ) internal view virtual returns (bool valid) { - bytes32 currentSignerId = bytes32(0); - uint256 signersLength = signingSigners.length; for (uint256 i = 0; i < signersLength; i++) { - // Signers must ordered by id to ensure no duplicates - bytes memory signer = signingSigners[i]; - bytes32 id = signerId(signer); - if (currentSignerId >= id || !isSigner(signer) || !signer.isValidSignatureNow(hash, signatures[i])) { + if (!isSigner(signingSigners[i])) { return false; } - - currentSignerId = id; } - - return true; + return hash.isValidNSignaturesNow(signingSigners, signatures, signerId); } /** From b63009a588939f9e3315d7edce698ee5ba923c66 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 25 Apr 2025 19:56:24 -0600 Subject: [PATCH 32/90] add tests --- contracts/mocks/ERC7913VerifierMock.sol | 19 +- contracts/utils/cryptography/ERC7913Utils.sol | 14 ++ test/utils/cryptography/ERC7913Utils.test.js | 162 +++++++++++++++++- 3 files changed, 186 insertions(+), 9 deletions(-) diff --git a/contracts/mocks/ERC7913VerifierMock.sol b/contracts/mocks/ERC7913VerifierMock.sol index ac0eeff4..d914b1f0 100644 --- a/contracts/mocks/ERC7913VerifierMock.sol +++ b/contracts/mocks/ERC7913VerifierMock.sol @@ -16,13 +16,22 @@ contract ERC7913VerifierMock is IERC7913SignatureVerifier { } function verify(bytes calldata key, bytes32 /* hash */, bytes calldata signature) external pure returns (bytes4) { - // For testing purposes, we'll only accept a specific key and signature combination - if ( - keccak256(key) == keccak256(abi.encodePacked("valid_key")) && - keccak256(signature) == keccak256(abi.encodePacked("valid_signature")) - ) { + // For testing purposes, we'll only accept specific key/signature combinations + if (_isKnownSigner1(key, signature) || _isKnownSigner2(key, signature)) { return IERC7913SignatureVerifier.verify.selector; } return 0xffffffff; } + + function _isKnownSigner1(bytes calldata key, bytes calldata signature) internal pure returns (bool) { + return + keccak256(key) == keccak256(abi.encodePacked("valid_key_1")) && + keccak256(signature) == keccak256(abi.encodePacked("valid_signature_1")); + } + + function _isKnownSigner2(bytes calldata key, bytes calldata signature) internal pure returns (bool) { + return + keccak256(key) == keccak256(abi.encodePacked("valid_key_2")) && + keccak256(signature) == keccak256(abi.encodePacked("valid_signature_2")); + } } diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol index 39dc1720..afdc7269 100644 --- a/contracts/utils/cryptography/ERC7913Utils.sol +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -84,4 +84,18 @@ library ERC7913Utils { return true; } + + /// @dev Overload of {isValidNSignaturesNow} that uses the `keccak256` as the `signerId` function. + function isValidNSignaturesNow( + bytes32 hash, + bytes[] memory signers, + bytes[] memory signatures + ) internal view returns (bool) { + return isValidNSignaturesNow(hash, signers, signatures, _keccak256); + } + + /// @dev Computes the keccak256 hash of the given data. + function _keccak256(bytes memory data) private pure returns (bytes32) { + return keccak256(data); + } } diff --git a/test/utils/cryptography/ERC7913Utils.test.js b/test/utils/cryptography/ERC7913Utils.test.js index 6760b839..b01071fb 100644 --- a/test/utils/cryptography/ERC7913Utils.test.js +++ b/test/utils/cryptography/ERC7913Utils.test.js @@ -1,6 +1,7 @@ const { expect } = require('chai'); const { ethers } = require('hardhat'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic'); const TEST_MESSAGE = ethers.id('OpenZeppelin'); const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE); @@ -9,43 +10,70 @@ const WRONG_MESSAGE = ethers.id('Nope'); const WRONG_MESSAGE_HASH = ethers.hashMessage(WRONG_MESSAGE); async function fixture() { - const [, signer, other] = await ethers.getSigners(); + const [, signer, other, extraSigner] = await ethers.getSigners(); const mock = await ethers.deployContract('$ERC7913Utils'); // Deploy a mock ERC-1271 wallet const wallet = await ethers.deployContract('ERC1271WalletMock', [signer]); + const wallet2 = await ethers.deployContract('ERC1271WalletMock', [extraSigner]); // Deploy a mock ERC-7913 verifier const verifier = await ethers.deployContract('ERC7913VerifierMock'); // Create test keys - const validKey = ethers.toUtf8Bytes('valid_key'); + const validKey = ethers.toUtf8Bytes('valid_key_1'); + const validKey2 = ethers.toUtf8Bytes('valid_key_2'); const invalidKey = ethers.randomBytes(32); // Create signer bytes (verifier address + key) const validSignerBytes = ethers.concat([verifier.target, validKey]); + const validSignerBytes2 = ethers.concat([verifier.target, validKey2]); const invalidKeySignerBytes = ethers.concat([verifier.target, invalidKey]); // Create test signatures - const validSignature = ethers.toUtf8Bytes('valid_signature'); + const validSignature = ethers.toUtf8Bytes('valid_signature_1'); + const validSignature2 = ethers.toUtf8Bytes('valid_signature_2'); const invalidSignature = ethers.randomBytes(65); - // Get EOA signature from the signer + // Get EOA signatures from the signers const eoaSignature = await signer.signMessage(TEST_MESSAGE); + const eoaSignature2 = await extraSigner.signMessage(TEST_MESSAGE); + const wrongMessageSignature = await signer.signMessage(WRONG_MESSAGE); + + // Create EOA signers + const eoaSigner = ethers.zeroPadValue(signer.address, 20); + const eoaSigner2 = ethers.zeroPadValue(extraSigner.address, 20); + const wrongSigner = ethers.zeroPadValue(other.address, 20); + + // Create Wallet signers + const walletSigner = ethers.zeroPadValue(wallet.target, 20); + const walletSigner2 = ethers.zeroPadValue(wallet2.target, 20); return { signer, other, + extraSigner, mock, wallet, + wallet2, verifier, validKey, + validKey2, invalidKey, validSignerBytes, + validSignerBytes2, invalidKeySignerBytes, validSignature, + validSignature2, invalidSignature, eoaSignature, + eoaSignature2, + wrongMessageSignature, + eoaSigner, + eoaSigner2, + wrongSigner, + walletSigner, + walletSigner2, }; } @@ -124,4 +152,130 @@ describe('ERC7913Utils', function () { }); }); }); + + describe('isValidNSignaturesNow', function () { + it('should validate a single signature', async function () { + await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, [this.eoaSigner], [this.eoaSignature])).to + .eventually.be.true; + }); + + it('should validate multiple signatures with different signer types', async function () { + // Order signers by ID (using keccak256) + const signers = [this.eoaSigner, this.walletSigner, this.validSignerBytes].sort( + (a, b) => ethers.keccak256(a) - ethers.keccak256(b), + ); + + // Create corresponding signatures in the same order + const signatures = signers.map(signer => { + if (ethers.dataLength(signer) === 20) { + // EOA or ERC-1271 wallet + if (ethers.getAddress(ethers.hexlify(signer)) === this.signer.address) { + return this.eoaSignature; + } else if (ethers.hexlify(signer) === ethers.hexlify(this.walletSigner)) { + return this.eoaSignature; // wallet uses signer's signature + } + } else { + // ERC-7913 verifier + return this.validSignature; + } + return ethers.randomBytes(65); // fallback, shouldn't be reached + }); + + await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + }); + + it('should validate multiple EOA signatures', async function () { + // Sort by signer ID + const signers = [this.eoaSigner, this.eoaSigner2].sort((a, b) => ethers.keccak256(a) - ethers.keccak256(b)); + + // Map of signer to signature + const signatureMap = { + [ethers.hexlify(this.eoaSigner)]: this.eoaSignature, + [ethers.hexlify(this.eoaSigner2)]: this.eoaSignature2, + }; + + const signatures = signers.map(signer => signatureMap[ethers.hexlify(signer)]); + + await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + }); + + it('should validate multiple ERC-1271 wallet signatures', async function () { + // Sort by signer ID + const signers = [this.walletSigner, this.walletSigner2].sort((a, b) => ethers.keccak256(a) - ethers.keccak256(b)); + + // Both wallets use their respective owner's signatures + const signatures = [this.eoaSignature, this.eoaSignature2]; + if (ethers.keccak256(this.walletSigner) - ethers.keccak256(this.walletSigner2) > 0) { + signatures.reverse(); + } + + await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + }); + + it('should validate multiple ERC-7913 signatures', async function () { + // Sort by signer ID + const signers = [this.validSignerBytes, this.validSignerBytes2].sort( + (a, b) => ethers.keccak256(a) - ethers.keccak256(b), + ); + + // Map of signer to signature + const signatureMap = { + [ethers.hexlify(this.validSignerBytes)]: this.validSignature, + [ethers.hexlify(this.validSignerBytes2)]: this.validSignature2, + }; + + const signatures = signers.map(signer => signatureMap[ethers.hexlify(signer)]); + + await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + }); + + it('should return false if any signature is invalid', async function () { + // Use two EOA signers but one signature is for the wrong message + await expect( + this.mock.$isValidNSignaturesNow( + TEST_MESSAGE_HASH, + [this.eoaSigner, this.eoaSigner2], + [this.eoaSignature, this.wrongMessageSignature], + ), + ).to.eventually.be.false; + }); + + it('should return false if signers are not ordered by ID', async function () { + // Ensure signers are ordered incorrectly + const signers = [this.eoaSigner, this.eoaSigner2]; + const signatures = [this.eoaSignature, this.eoaSignature2]; + + // If they're already ordered, swap them + if (ethers.keccak256(signers[0]) - ethers.keccak256(signers[1])) { + signers.reverse(); + signatures.reverse(); + } + + await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.false; + }); + + it('should return false if there are duplicate signers', async function () { + await expect( + this.mock.$isValidNSignaturesNow( + TEST_MESSAGE_HASH, + [this.eoaSigner, this.eoaSigner], // Same signer used twice + [this.eoaSignature, this.eoaSignature], + ), + ).to.eventually.be.false; + }); + + it('should fail if signatures array length does not match signers array length', async function () { + await expect( + this.mock.$isValidNSignaturesNow( + TEST_MESSAGE_HASH, + [this.eoaSigner, this.eoaSigner2], + [this.eoaSignature], // Missing one signature + ), + ).to.be.revertedWithPanic(PANIC_CODES.ARRAY_ACCESS_OUT_OF_BOUNDS); + }); + + it('should pass with empty arrays', async function () { + await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, [], [])).to.eventually.be.true; + }); + }); }); From 4ec4be3b8364f8d8b151c305e0d7b5f7d40ec3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Fri, 25 Apr 2025 19:58:14 -0600 Subject: [PATCH 33/90] Update contracts/utils/cryptography/MultiSignerERC7913Weighted.sol --- contracts/utils/cryptography/MultiSignerERC7913Weighted.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 7cae6e9a..686d65be 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -126,7 +126,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { uint256 removedWeight = _weightSigners(oldSigners); _totalWeight -= removedWeight.toUint128(); // Clean up weights for removed signers - _setSignerWeights(signers, new uint256[](oldSigners.length)); + _setSignerWeights(oldSigners, new uint256[](oldSigners.length)); super._removeSigners(oldSigners); } From 81e6e26683f51ff29500c843fec745cdbe5808b3 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 25 Apr 2025 22:41:29 -0600 Subject: [PATCH 34/90] Add ERC7579SignatureValidator --- .../modules/ERC7579SignatureValidator.sol | 131 ++++++++++++++++++ .../account/extensions/AccountERC7579.test.js | 120 ++++++++++++---- 2 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 contracts/account/modules/ERC7579SignatureValidator.sol diff --git a/contracts/account/modules/ERC7579SignatureValidator.sol b/contracts/account/modules/ERC7579SignatureValidator.sol new file mode 100644 index 00000000..d7883bd3 --- /dev/null +++ b/contracts/account/modules/ERC7579SignatureValidator.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7579Validator, IERC7579Module, MODULE_TYPE_VALIDATOR} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; +import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {ERC7913Utils} from "../../utils/cryptography/ERC7913Utils.sol"; + +/** + * @dev Implementation of {IERC7579Validator} module using ERC-7913 signature verification. + * + * This validator allows ERC-7579 accounts to integrate with address-less cryptographic keys + * through the ERC-7913 signature verification system. Each account can store its own ERC-7913 + * formatted signer (a concatenation of a verifier address and a key: `verifier || key`). + * + * This enables accounts to use signature schemes without requiring each key to have its own + * Ethereum address. + * + * The validator implements two key functions from ERC-7579: + * - `validateUserOp`: Validates ERC-4337 user operations using ERC-7913 signatures + * - `isValidSignatureWithSender`: Implements ERC-1271 signature verification via ERC-7913 + * + * Example usage with an account: + * + * ```solidity + * contract MyAccount is Account, AccountERC7579 { + * function initialize(address validator, bytes memory signerData) public initializer { + * // Install the validator module + * bytes memory initData = abi.encode(signerData); + * _installModule(MODULE_TYPE_VALIDATOR, validator, initData); + * } + * } + * ``` + * + * Example of validator installation with a P256 key: + * + * ```solidity + * // Address of the P256 verifier contract + * address p256verifier = 0x123...; + * + * // P256 public key bytes + * bytes memory p256PublicKey = 0x456...; + * + * // Combine into ERC-7913 signer format + * bytes memory signerData = bytes.concat(abi.encodePacked(p256verifier), p256PublicKey); + * + * // Initialize the account with the validator and signer + * account.initialize(address(new ERC7579SignatureValidator()), signerData); + * ``` + */ +contract ERC7579SignatureValidator is IERC7579Validator { + mapping(address account => bytes signer) private _signers; + + /// @dev Emitted when the signer is set. + event ERC7579SignatureValidatorSignerSet(address indexed account, bytes signer); + + /// @dev Thrown when the signer length is less than 20 bytes. + error ERC7579SignatureValidatorInvalidSignerLength(); + + /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). + function signer(address account) public view virtual returns (bytes memory) { + return _signers[account]; + } + + /// @inheritdoc IERC7579Module + function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { + return moduleTypeId == MODULE_TYPE_VALIDATOR; + } + + /// @inheritdoc IERC7579Module + function onInstall(bytes calldata data) public virtual { + require(data.length >= 20, ERC7579SignatureValidatorInvalidSignerLength()); + _setSigner(msg.sender, data); + } + + /// @inheritdoc IERC7579Module + function onUninstall(bytes calldata) public virtual { + _setSigner(msg.sender, ""); + } + + /// @inheritdoc IERC7579Validator + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) public view virtual returns (uint256) { + return + _isValidSignatureWithSender(msg.sender, userOpHash, userOp.signature) + ? ERC4337Utils.SIG_VALIDATION_SUCCESS + : ERC4337Utils.SIG_VALIDATION_FAILED; + } + + /// @inheritdoc IERC7579Validator + function isValidSignatureWithSender( + address sender, + bytes32 hash, + bytes calldata signature + ) public view virtual returns (bytes4) { + return + _isValidSignatureWithSender(sender, hash, signature) + ? IERC1271.isValidSignature.selector + : bytes4(0xffffffff); + } + + /// @dev Sets the ERC-7913 signer (i.e. `verifier || key`) for the calling account. + function setSigner(bytes memory signer_) public virtual { + _setSigner(msg.sender, signer_); + } + + /// @dev Internal version of {setSigner} that takes an `account` as argument. + function _setSigner(address account, bytes memory signer_) internal { + _signers[account] = signer_; + emit ERC7579SignatureValidatorSignerSet(account, signer_); + } + + /** + * @dev Validates a `signature` using ERC-7913 verification. + * + * The base implementation ignores the `sender` parameter and validates using + * the account's stored signer. Derived contracts can override this to implement + * custom validation logic based on the sender. + */ + function _isValidSignatureWithSender( + address /* sender */, + bytes32 hash, + bytes calldata signature + ) internal view virtual returns (bool) { + return ERC7913Utils.isValidSignatureNow(signer(msg.sender), hash, signature); + } +} diff --git a/test/account/extensions/AccountERC7579.test.js b/test/account/extensions/AccountERC7579.test.js index f64e63c4..71653693 100644 --- a/test/account/extensions/AccountERC7579.test.js +++ b/test/account/extensions/AccountERC7579.test.js @@ -4,57 +4,119 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); const { ERC4337Helper } = require('../../helpers/erc4337'); const { PackedUserOperation } = require('../../helpers/eip712-types'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../../helpers/signers'); const { shouldBehaveLikeAccountCore } = require('../Account.behavior'); const { shouldBehaveLikeAccountERC7579 } = require('./AccountERC7579.behavior'); const { shouldBehaveLikeERC1271 } = require('../../utils/cryptography/ERC1271.behavior'); +// Prepare signers in advance (RSA are long to initialize) +const signerECDSA = ethers.Wallet.createRandom(); +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + async function fixture() { // EOAs and environment const [other] = await ethers.getSigners(); const target = await ethers.deployContract('CallReceiverMockExtended'); const anotherTarget = await ethers.deployContract('CallReceiverMockExtended'); - // ERC-7579 validator - const validator = await ethers.deployContract('$ERC7579ValidatorMock'); + // ERC-7579 signature validator + const signatureValidator = await ethers.deployContract('$ERC7579SignatureValidator'); - // ERC-4337 signer - const signer = ethers.Wallet.createRandom(); + // ERC-7913 verifiers + const verifierP256 = await ethers.deployContract('ERC7913SignatureVerifierP256'); + const verifierRSA = await ethers.deployContract('ERC7913SignatureVerifierRSA'); - // ERC-4337 account + // ERC-4337 env const helper = new ERC4337Helper(); - const mock = await helper.newAccount('$AccountERC7579Mock', [ - validator, - ethers.solidityPacked(['address'], [signer.address]), - ]); - - // ERC-4337 Entrypoint domain + await helper.wait(); const entrypointDomain = await getDomain(entrypoint.v08); + const domain = { + name: 'AccountERC7579', + version: '1', + chainId: entrypointDomain.chainId, + verifyingContract: signatureValidator.target, + }; + + const signUserOp = function (userOp) { + return this.signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature: ethers.concat([signatureValidator.target, signature]) })); + }; - return { helper, validator, mock, entrypointDomain, signer, target, anotherTarget, other }; + const makeAccount = function (signer) { + return this.helper.newAccount('$AccountERC7579Mock', [this.signatureValidator, signer]); + }; + + return { + helper, + signatureValidator, + verifierP256, + verifierRSA, + domain, + target, + anotherTarget, + other, + signUserOp, + makeAccount, + }; } describe('AccountERC7579', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); + }); + + // Using ECDSA key as verifier + describe('ECDSA key', function () { + beforeEach(async function () { + this.signer = signerECDSA; + this.mock = await this.makeAccount(ethers.solidityPacked(['address'], [this.signer.address])); + }); - this.signer.signMessage = message => - ethers.Wallet.prototype.signMessage - .bind(this.signer)(message) - .then(sign => ethers.concat([this.validator.target, sign])); - this.signer.signTypedData = (domain, types, values) => - ethers.Wallet.prototype.signTypedData - .bind(this.signer)(domain, types, values) - .then(sign => ethers.concat([this.validator.target, sign])); - this.signUserOp = userOp => - ethers.Wallet.prototype.signTypedData - .bind(this.signer)(this.entrypointDomain, { PackedUserOperation }, userOp.packed) - .then(signature => Object.assign(userOp, { signature })); - - this.userOp = { nonce: ethers.zeroPadBytes(ethers.hexlify(this.validator.target), 32) }; + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountERC7579(); + shouldBehaveLikeERC1271(); }); - shouldBehaveLikeAccountCore(); - shouldBehaveLikeAccountERC7579(); - shouldBehaveLikeERC1271(); + // Using P256 key with an ERC-7913 verifier + describe('P256 key', function () { + beforeEach(async function () { + this.signer = signerP256; + this.mock = await this.helper.newAccount('$AccountERC7579Mock', [ + this.signatureValidator, + ethers.concat([ + this.verifierP256.target, + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]), + ]); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountERC7579(); + shouldBehaveLikeERC1271(); + }); + + // Using RSA key with an ERC-7913 verifier + describe('RSA key', function () { + beforeEach(async function () { + this.signer = signerRSA; + this.mock = await this.helper.newAccount('$AccountERC7579Mock', [ + this.signatureValidator, + ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [this.signer.signingKey.publicKey.e, this.signer.signingKey.publicKey.n], + ), + ]), + ]); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountERC7579(); + shouldBehaveLikeERC1271(); + }); }); From d316450fbb631917e7de08d9826502ff3287a178 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 10:58:54 -0600 Subject: [PATCH 35/90] Fix tests add docs --- .../utils/cryptography/MultiSignerERC7913.sol | 42 +++++++++++++++-- .../MultiSignerERC7913Weighted.sol | 46 ++++++++++++++----- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index 4076365c..f35d8715 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -105,7 +105,15 @@ abstract contract MultiSignerERC7913 is AbstractSigner { return _signersSet; } - /// @dev Adds the `newSigners` to those allowed to sign on behalf of this contract. Internal version without access control. + /** + * @dev Adds the `newSigners` to those allowed to sign on behalf of this contract. + * Internal version without access control. + * + * Requirements: + * + * * Each of `newSigners` must be at least 20 bytes long. Reverts with {MultiSignerERC7913InvalidSigner} if not. + * * Each of `newSigners` must not be authorized. See {isSigner}. Reverts with {MultiSignerERC7913AlreadyExists} if so. + */ function _addSigners(bytes[] memory newSigners) internal virtual { for (uint256 i = 0; i < newSigners.length; i++) { bytes memory signer = newSigners[i]; @@ -115,7 +123,14 @@ abstract contract MultiSignerERC7913 is AbstractSigner { emit ERC7913SignersAdded(newSigners); } - /// @dev Removes the `oldSigners` from the authorized signers. Internal version without access control. + /** + * @dev Removes the `oldSigners` from the authorized signers. Internal version without access control. + * + * Requirements: + * + * * Each of `oldSigners` must be authorized. See {isSigner}. Otherwise {MultiSignerERC7913NonexistentSigner} is thrown. + * * See {_validateReachableThreshold} for the threshold validation. + */ function _removeSigners(bytes[] memory oldSigners) internal virtual { for (uint256 i = 0; i < oldSigners.length; i++) { bytes memory signer = oldSigners[i]; @@ -125,14 +140,27 @@ abstract contract MultiSignerERC7913 is AbstractSigner { emit ERC7913SignersRemoved(oldSigners); } - /// @dev Sets the signatures `threshold` required to approve a multisignature operation. Internal version without access control. + /** + * @dev Sets the signatures `threshold` required to approve a multisignature operation. + * Internal version without access control. + * + * Requirements: + * + * * See {_validateReachableThreshold} for the threshold validation. + */ function _setThreshold(uint256 newThreshold) internal virtual { _threshold = newThreshold.toUint128(); _validateReachableThreshold(); emit ERC7913ThresholdSet(newThreshold); } - /// @dev Validates the current threshold is reachable. + /** + * @dev Validates the current threshold is reachable. + * + * Requirements: + * + * * The {signers}'s length must be `>=` to the {threshold}. Throws {MultiSignerERC7913UnreachableThreshold} if not. + */ function _validateReachableThreshold() internal view virtual { uint256 totalSigners = _signers().length(); uint256 currentThreshold = threshold(); @@ -170,6 +198,10 @@ abstract contract MultiSignerERC7913 is AbstractSigner { * // Encode the multi signature * bytes memory signature = abi.encode(signers, signatures); * ``` + * + * Requirements: + * + * * The `signature` must be encoded as `abi.encode(signers, signatures)`. */ function _rawSignatureValidation( bytes32 hash, @@ -191,7 +223,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { * * Requirements: * - * - The `signers` and `signatures` arrays must be of the same length. + * * The `signatures` arrays must be at least as large as the `signingSigners` arrays. Panics otherwise. */ function _validateNSignatures( bytes32 hash, diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 686d65be..74ac8e6b 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -94,24 +94,23 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { * - `signers` and `weights` arrays must have the same length. Reverts with {MultiSignerERC7913WeightedMismatchedLength} on mismatch. * - Each signer must exist in the set of authorized signers. Reverts with {MultiSignerERC7913NonexistentSigner} if not. * - Each weight must be greater than 0. Reverts with {MultiSignerERC7913WeightedInvalidWeight} if not. + * - See {_validateReachableThreshold} for the threshold validation. + * + * Emits {ERC7913SignerWeightChanged} for each signer. */ function _setSignerWeights(bytes[] memory signers, uint256[] memory newWeights) internal virtual { require(signers.length == newWeights.length, MultiSignerERC7913WeightedMismatchedLength()); - uint128 cachedTotalWeight = _totalWeight; + uint256 oldWeight = _weightSigners(signers); for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; uint256 newWeight = newWeights[i]; require(isSigner(signer), MultiSignerERC7913NonexistentSigner(signer)); require(newWeight > 0, MultiSignerERC7913WeightedInvalidWeight(signer, newWeight)); - - uint256 oldWeight = _signerWeight(signer); - _weights[signerId(signer)] = newWeight; - cachedTotalWeight = (cachedTotalWeight + newWeight - oldWeight).toUint128(); - emit ERC7913SignerWeightChanged(signer, newWeight); } - _totalWeight = cachedTotalWeight; + _unsafeSetSignerWeights(signers, newWeights); + _totalWeight = (_totalWeight - oldWeight + _weightSigners(signers)).toUint128(); _validateReachableThreshold(); } @@ -121,18 +120,29 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { _totalWeight += newSigners.length.toUint128(); // Each new signer has a default weight of 1 } - /// @inheritdoc MultiSignerERC7913 + /** + * @dev See {MultiSignerERC7913-_removeSigners}. + * + * Emits {ERC7913SignerWeightChanged} for each removed signer. + */ function _removeSigners(bytes[] memory oldSigners) internal virtual override { uint256 removedWeight = _weightSigners(oldSigners); - _totalWeight -= removedWeight.toUint128(); + unchecked { + // Can't overflow. Invariant: sum(weights) >= threshold + _totalWeight -= removedWeight.toUint128(); + } // Clean up weights for removed signers - _setSignerWeights(oldSigners, new uint256[](oldSigners.length)); + _unsafeSetSignerWeights(oldSigners, new uint256[](oldSigners.length)); super._removeSigners(oldSigners); } /** * @dev Sets the threshold for the multisignature operation. Internal version without access control. * + * Requirements: + * + * * The {totalWeight} must be `>=` to the {threshold}. Throws {MultiSignerERC7913UnreachableThreshold} if not. + * * 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 @@ -145,7 +155,7 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { } /** - * @dev Overrides the threshold validation to use signer weights. + * @dev Validates that the total weight of signers meets the threshold requirement. * * NOTE: This function intentionally does not call `super. _validateThreshold` because the base implementation * assumes each signer has a weight of 1, which is a subset of this weighted implementation. Consider that multiple @@ -165,4 +175,18 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { } return weight; } + + /** + * @dev Sets the weights for multiple signers without updating the total weight or validating the threshold. + * + * Requirements: + * + * * The `newWeights` array must be at least as large as the `signers` array. Panics otherwise. + */ + function _unsafeSetSignerWeights(bytes[] memory signers, uint256[] memory newWeights) private { + for (uint256 i = 0; i < signers.length; i++) { + _weights[signerId(signers[i])] = newWeights[i]; + emit ERC7913SignerWeightChanged(signers[i], newWeights[i]); + } + } } From e0361ca1761d69e4818ec2c9a114ebad296edd1e Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 11:24:19 -0600 Subject: [PATCH 36/90] Avoid using private _signersSet --- contracts/utils/cryptography/MultiSignerERC7913.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index f35d8715..9b668efc 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -87,7 +87,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { * if the signers set grows too large. */ function signers() public view virtual returns (bytes[] memory) { - return _signersSet.values(); + return _signers().values(); } /// @dev Returns whether the `signer` is an authorized signer. @@ -118,7 +118,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { for (uint256 i = 0; i < newSigners.length; i++) { bytes memory signer = newSigners[i]; require(signer.length >= 20, MultiSignerERC7913InvalidSigner(signer)); - require(_signersSet.add(signer), MultiSignerERC7913AlreadyExists(signer)); + require(_signers().add(signer), MultiSignerERC7913AlreadyExists(signer)); } emit ERC7913SignersAdded(newSigners); } @@ -134,7 +134,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { function _removeSigners(bytes[] memory oldSigners) internal virtual { for (uint256 i = 0; i < oldSigners.length; i++) { bytes memory signer = oldSigners[i]; - require(_signersSet.remove(signer), MultiSignerERC7913NonexistentSigner(signer)); + require(_signers().remove(signer), MultiSignerERC7913NonexistentSigner(signer)); } _validateReachableThreshold(); emit ERC7913SignersRemoved(oldSigners); From aec67c32caed916b4add0970eb5511ff6ccf5257 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 14:52:18 -0600 Subject: [PATCH 37/90] Fix tests --- .../account/extensions/AccountERC7579.test.js | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/test/account/extensions/AccountERC7579.test.js b/test/account/extensions/AccountERC7579.test.js index 71653693..abbda6e8 100644 --- a/test/account/extensions/AccountERC7579.test.js +++ b/test/account/extensions/AccountERC7579.test.js @@ -22,7 +22,7 @@ async function fixture() { const anotherTarget = await ethers.deployContract('CallReceiverMockExtended'); // ERC-7579 signature validator - const signatureValidator = await ethers.deployContract('$ERC7579SignatureValidator'); + const erc7579Validator = await ethers.deployContract('$ERC7579SignatureValidator'); // ERC-7913 verifiers const verifierP256 = await ethers.deployContract('ERC7913SignatureVerifierP256'); @@ -36,33 +36,43 @@ async function fixture() { name: 'AccountERC7579', version: '1', chainId: entrypointDomain.chainId, - verifyingContract: signatureValidator.target, - }; - - const signUserOp = function (userOp) { - return this.signer - .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) - .then(signature => Object.assign(userOp, { signature: ethers.concat([signatureValidator.target, signature]) })); + verifyingContract: erc7579Validator.target, }; const makeAccount = function (signer) { - return this.helper.newAccount('$AccountERC7579Mock', [this.signatureValidator, signer]); + return this.helper.newAccount('$AccountERC7579Mock', [this.erc7579Validator, signer]); }; return { helper, - signatureValidator, + erc7579Validator, verifierP256, verifierRSA, + entrypointDomain, domain, target, anotherTarget, other, - signUserOp, makeAccount, + userOp: { + nonce: ethers.zeroPadBytes(ethers.hexlify(erc7579Validator.target), 32), + }, }; } +function prepareSigner(prototype) { + this.signer.signMessage = message => + prototype.signMessage.call(this.signer, message).then(sign => ethers.concat([this.erc7579Validator.target, sign])); + this.signer.signTypedData = (domain, types, values) => + prototype.signTypedData + .call(this.signer, domain, types, values) + .then(sign => ethers.concat([this.erc7579Validator.target, sign])); + this.signUserOp = userOp => + prototype.signTypedData + .call(this.signer, this.entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); +} + describe('AccountERC7579', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); @@ -72,6 +82,7 @@ describe('AccountERC7579', function () { describe('ECDSA key', function () { beforeEach(async function () { this.signer = signerECDSA; + prepareSigner.call(this, ethers.Wallet.prototype); this.mock = await this.makeAccount(ethers.solidityPacked(['address'], [this.signer.address])); }); @@ -84,8 +95,9 @@ describe('AccountERC7579', function () { describe('P256 key', function () { beforeEach(async function () { this.signer = signerP256; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); this.mock = await this.helper.newAccount('$AccountERC7579Mock', [ - this.signatureValidator, + this.erc7579Validator, ethers.concat([ this.verifierP256.target, this.signer.signingKey.publicKey.qx, @@ -103,8 +115,9 @@ describe('AccountERC7579', function () { describe('RSA key', function () { beforeEach(async function () { this.signer = signerRSA; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); this.mock = await this.helper.newAccount('$AccountERC7579Mock', [ - this.signatureValidator, + this.erc7579Validator, ethers.concat([ this.verifierRSA.target, ethers.AbiCoder.defaultAbiCoder().encode( From 822c6786328076e40cbafea43885da1e53810c96 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 17:27:51 -0600 Subject: [PATCH 38/90] Improve tests --- .../modules/ERC7579SignatureValidator.sol | 21 ++- test/account/modules/ERC7579.behavior.js | 69 ++++++++ .../modules/ERC7579SignatureValidator.test.js | 147 ++++++++++++++++++ test/utils/cryptography/ERC7913Utils.test.js | 4 +- 4 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 test/account/modules/ERC7579.behavior.js create mode 100644 test/account/modules/ERC7579SignatureValidator.test.js diff --git a/contracts/account/modules/ERC7579SignatureValidator.sol b/contracts/account/modules/ERC7579SignatureValidator.sol index d7883bd3..9cd0cca3 100644 --- a/contracts/account/modules/ERC7579SignatureValidator.sol +++ b/contracts/account/modules/ERC7579SignatureValidator.sol @@ -59,6 +59,9 @@ contract ERC7579SignatureValidator is IERC7579Validator { /// @dev Thrown when the signer length is less than 20 bytes. error ERC7579SignatureValidatorInvalidSignerLength(); + /// @dev Thrown when the module is already installed. + error ERC7579SignatureValidatorAlreadyInstalled(); + /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). function signer(address account) public view virtual returns (bytes memory) { return _signers[account]; @@ -69,13 +72,27 @@ contract ERC7579SignatureValidator is IERC7579Validator { return moduleTypeId == MODULE_TYPE_VALIDATOR; } - /// @inheritdoc IERC7579Module + /** + * @dev See {IERC7579Module-onInstall}. + * Reverts with {ERC7579SignatureValidatorAlreadyInstalled} if the module is already installed. + * + * IMPORTANT: A signer will be set for the calling account. In case the account calls this function + * directly, the signer will be set to the provided data even if the account didn't track + * the module's installation. Future installations will revert. + */ function onInstall(bytes calldata data) public virtual { require(data.length >= 20, ERC7579SignatureValidatorInvalidSignerLength()); + require(signer(msg.sender).length == 0, ERC7579SignatureValidatorAlreadyInstalled()); _setSigner(msg.sender, data); } - /// @inheritdoc IERC7579Module + /** + * @dev See {IERC7579Module-onUninstall}. + * + * WARNING: The signer's key will be removed if the account calls this function, potentially + * making the account unusable. As an account operator, make sure to uninstall to a predefined path + * in your account that properly side effects of uninstallation. See {AccountERC7579-uninstallModule}. + */ function onUninstall(bytes calldata) public virtual { _setSigner(msg.sender, ""); } diff --git a/test/account/modules/ERC7579.behavior.js b/test/account/modules/ERC7579.behavior.js new file mode 100644 index 00000000..528a10b1 --- /dev/null +++ b/test/account/modules/ERC7579.behavior.js @@ -0,0 +1,69 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('@openzeppelin/contracts/test/helpers/erc4337'); + +function shouldBehaveLikeERC7579Module() { + describe('behaves like ERC7579Module', function () { + it('identifies its module type correctly', async function () { + await expect(this.mock.isModuleType(this.moduleType)).to.eventually.be.true; + await expect(this.mock.isModuleType(999)).to.eventually.be.false; // Using random unassigned module type + }); + + it('handles installation and uninstallation', async function () { + await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted; + await expect(this.mockFromAccount.onUninstall(this.uninstallData || '0x')).to.not.be.reverted; + }); + }); +} + +function shouldBehaveLikeERC7579Validator() { + describe('behaves like ERC7579Validator', function () { + const MAGIC_VALUE = '0x1626ba7e'; + const INVALID_VALUE = '0xffffffff'; + + beforeEach(async function () { + await this.mockFromAccount.onInstall(this.installData); + }); + + describe('validateUserOp', function () { + it('returns SIG_VALIDATION_SUCCESS when signature is valid', async function () { + const userOp = await this.mockAccount.createUserOp(this.userOp).then(op => this.signUserOp(op)); + await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( + SIG_VALIDATION_SUCCESS, + ); + }); + + it('returns SIG_VALIDATION_FAILURE when signature is invalid', async function () { + const userOp = await this.mockAccount.createUserOp(this.userOp); + userOp.signature = this.invalidSignature || '0x00'; + await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( + SIG_VALIDATION_FAILURE, + ); + }); + }); + + describe('isValidSignatureWithSender', function () { + it('returns magic value for valid signature', async function () { + const message = 'Hello, world!'; + const hash = ethers.hashMessage(message); + const signature = await this.signer.signMessage(message); + await expect(this.mockFromAccount.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( + MAGIC_VALUE, + ); + }); + + it('returns failure value for invalid signature', async function () { + const hash = ethers.hashMessage('Hello, world!'); + const signature = this.invalidSignature || '0x00'; + await expect(this.mock.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( + INVALID_VALUE, + ); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC7579Module, + shouldBehaveLikeERC7579Validator, +}; diff --git a/test/account/modules/ERC7579SignatureValidator.test.js b/test/account/modules/ERC7579SignatureValidator.test.js new file mode 100644 index 00000000..c5130e50 --- /dev/null +++ b/test/account/modules/ERC7579SignatureValidator.test.js @@ -0,0 +1,147 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { expect } = require('chai'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { PackedUserOperation } = require('../../helpers/eip712-types'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../../helpers/signers'); + +const { MODULE_TYPE_VALIDATOR } = require('@openzeppelin/contracts/test/helpers/erc7579'); +const { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Validator } = require('./ERC7579.behavior'); + +// Prepare signers in advance (RSA are long to initialize) +const signerECDSA = ethers.Wallet.createRandom(); +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 signature validator + const mock = await ethers.deployContract('$ERC7579SignatureValidator'); + + // ERC-7913 verifiers + const verifierP256 = await ethers.deployContract('ERC7913SignatureVerifierP256'); + const verifierRSA = await ethers.deployContract('ERC7913SignatureVerifierRSA'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(entrypoint.v08); + + // ERC-7579 account + const mockAccount = await helper.newAccount('$AccountERC7579'); + const mockFromAccount = await impersonate(mockAccount.address).then(asAccount => mock.connect(asAccount)); + + return { + moduleType: MODULE_TYPE_VALIDATOR, + mock, + verifierP256, + verifierRSA, + mockFromAccount, + entrypointDomain, + mockAccount, + other, + }; +} + +function prepareSigner(prototype) { + this.signUserOp = userOp => + prototype.signTypedData + .call(this.signer, this.entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); +} + +describe('ERC7579SignatureValidator', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('reverts with ERC7579SignatureValidatorInvalidSignerLength when signer length is less than 20 bytes', async function () { + const shortSigner = '0x0123456789'; // Less than 20 bytes + await expect(this.mockFromAccount.onInstall(shortSigner)).to.be.revertedWithCustomError( + this.mock, + 'ERC7579SignatureValidatorInvalidSignerLength', + ); + }); + + it('reverts with ERC7579SignatureValidatorAlreadyInstalled when the validator is already installed for an account', async function () { + // First installation should succeed + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await expect(this.mockFromAccount.onInstall(signerData)).to.not.be.reverted; + + // Second installation should fail + await expect(this.mockFromAccount.onInstall(signerData)).to.be.revertedWithCustomError( + this.mock, + 'ERC7579SignatureValidatorAlreadyInstalled', + ); + }); + + it('emits event on ERC7579SignatureValidatorSignerSet on both installation and uninstallation', async function () { + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + + // First install + await expect(this.mockFromAccount.onInstall(signerData)) + .to.emit(this.mock, 'ERC7579SignatureValidatorSignerSet') + .withArgs(this.mockAccount.address, signerData); + + // Then uninstall + await expect(this.mockFromAccount.onUninstall('0x')) + .to.emit(this.mock, 'ERC7579SignatureValidatorSignerSet') + .withArgs(this.mockAccount.address, '0x'); + }); + + it('returns the correct signer bytes when set', async function () { + // Starts empty + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal('0x'); + + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await this.mockFromAccount.onInstall(signerData); + + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); + }); + + describe('ECDSA key', function () { + beforeEach(async function () { + this.signer = signerECDSA; + prepareSigner.call(this, ethers.Wallet.prototype); + this.installData = ethers.solidityPacked(['address'], [this.signer.address]); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); + + describe('P256 key', function () { + beforeEach(async function () { + this.signer = signerP256; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); + this.installData = ethers.concat([ + this.verifierP256.target, + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); + + describe('RSA key', function () { + beforeEach(async function () { + this.signer = signerRSA; + prepareSigner.call(this, new NonNativeSigner(this.signer.signingKey)); + this.installData = ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [this.signer.signingKey.publicKey.e, this.signer.signingKey.publicKey.n], + ), + ]); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Validator(); + }); +}); diff --git a/test/utils/cryptography/ERC7913Utils.test.js b/test/utils/cryptography/ERC7913Utils.test.js index b01071fb..2f7665b3 100644 --- a/test/utils/cryptography/ERC7913Utils.test.js +++ b/test/utils/cryptography/ERC7913Utils.test.js @@ -3,10 +3,10 @@ const { ethers } = require('hardhat'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic'); -const TEST_MESSAGE = ethers.id('OpenZeppelin'); +const TEST_MESSAGE = 'OpenZeppelin'; const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE); -const WRONG_MESSAGE = ethers.id('Nope'); +const WRONG_MESSAGE = 'Nope'; const WRONG_MESSAGE_HASH = ethers.hashMessage(WRONG_MESSAGE); async function fixture() { From 43779639deeb1f3613d9ad481752f4d0687f7cae Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 17:45:49 -0600 Subject: [PATCH 39/90] Up --- .../account/modules/ERC7579SignatureValidator.sol | 4 ++-- .../modules/ERC7579SignatureValidator.test.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/contracts/account/modules/ERC7579SignatureValidator.sol b/contracts/account/modules/ERC7579SignatureValidator.sol index 9cd0cca3..b3de30c0 100644 --- a/contracts/account/modules/ERC7579SignatureValidator.sol +++ b/contracts/account/modules/ERC7579SignatureValidator.sol @@ -81,9 +81,8 @@ contract ERC7579SignatureValidator is IERC7579Validator { * the module's installation. Future installations will revert. */ function onInstall(bytes calldata data) public virtual { - require(data.length >= 20, ERC7579SignatureValidatorInvalidSignerLength()); require(signer(msg.sender).length == 0, ERC7579SignatureValidatorAlreadyInstalled()); - _setSigner(msg.sender, data); + setSigner(data); } /** @@ -122,6 +121,7 @@ contract ERC7579SignatureValidator is IERC7579Validator { /// @dev Sets the ERC-7913 signer (i.e. `verifier || key`) for the calling account. function setSigner(bytes memory signer_) public virtual { + require(signer_.length >= 20, ERC7579SignatureValidatorInvalidSignerLength()); _setSigner(msg.sender, signer_); } diff --git a/test/account/modules/ERC7579SignatureValidator.test.js b/test/account/modules/ERC7579SignatureValidator.test.js index c5130e50..accbd5ca 100644 --- a/test/account/modules/ERC7579SignatureValidator.test.js +++ b/test/account/modules/ERC7579SignatureValidator.test.js @@ -102,6 +102,21 @@ describe('ERC7579SignatureValidator', function () { await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); }); + it('sets signer correctly with setSigner and emits event', async function () { + const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); + await expect(this.mockFromAccount.setSigner(signerData)) + .to.emit(this.mockFromAccount, 'ERC7579SignatureValidatorSignerSet') + .withArgs(this.mockAccount.address, signerData); + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); + }); + + it('reverts when calling setSigner with invalid signer length', async function () { + await expect(this.mock.setSigner('0x0123456789')).to.be.revertedWithCustomError( + this.mock, + 'ERC7579SignatureValidatorInvalidSignerLength', + ); + }); + describe('ECDSA key', function () { beforeEach(async function () { this.signer = signerECDSA; From ffd8d2339f5625c3f7933c3da0bab3ce6b8d20d4 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 18:07:34 -0600 Subject: [PATCH 40/90] Rename --- contracts/utils/cryptography/ERC7913Utils.sol | 8 +++---- .../utils/cryptography/MultiSignerERC7913.sol | 2 +- test/utils/cryptography/ERC7913Utils.test.js | 22 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol index afdc7269..5360b6ea 100644 --- a/contracts/utils/cryptography/ERC7913Utils.sol +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -62,7 +62,7 @@ library ERC7913Utils { * NOTE: The `signerId` function argument must be deterministic and should not manipulate * memory state directly and should follow Solidity memory safety rules to avoid unexpected behavior. */ - function isValidNSignaturesNow( + function areValidNSignaturesNow( bytes32 hash, bytes[] memory signers, bytes[] memory signatures, @@ -85,13 +85,13 @@ library ERC7913Utils { return true; } - /// @dev Overload of {isValidNSignaturesNow} that uses the `keccak256` as the `signerId` function. - function isValidNSignaturesNow( + /// @dev Overload of {areValidNSignaturesNow} that uses the `keccak256` as the `signerId` function. + function areValidNSignaturesNow( bytes32 hash, bytes[] memory signers, bytes[] memory signatures ) internal view returns (bool) { - return isValidNSignaturesNow(hash, signers, signatures, _keccak256); + return areValidNSignaturesNow(hash, signers, signatures, _keccak256); } /// @dev Computes the keccak256 hash of the given data. diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol index 58b93537..da91c53a 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -204,7 +204,7 @@ abstract contract MultiSignerERC7913 is AbstractSigner { return false; } } - return hash.isValidNSignaturesNow(signingSigners, signatures, signerId); + return hash.areValidNSignaturesNow(signingSigners, signatures, signerId); } /** diff --git a/test/utils/cryptography/ERC7913Utils.test.js b/test/utils/cryptography/ERC7913Utils.test.js index b01071fb..e85134da 100644 --- a/test/utils/cryptography/ERC7913Utils.test.js +++ b/test/utils/cryptography/ERC7913Utils.test.js @@ -153,9 +153,9 @@ describe('ERC7913Utils', function () { }); }); - describe('isValidNSignaturesNow', function () { + describe('areValidNSignaturesNow', function () { it('should validate a single signature', async function () { - await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, [this.eoaSigner], [this.eoaSignature])).to + await expect(this.mock.$areValidNSignaturesNow(TEST_MESSAGE_HASH, [this.eoaSigner], [this.eoaSignature])).to .eventually.be.true; }); @@ -181,7 +181,7 @@ describe('ERC7913Utils', function () { return ethers.randomBytes(65); // fallback, shouldn't be reached }); - await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + await expect(this.mock.$areValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; }); it('should validate multiple EOA signatures', async function () { @@ -196,7 +196,7 @@ describe('ERC7913Utils', function () { const signatures = signers.map(signer => signatureMap[ethers.hexlify(signer)]); - await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + await expect(this.mock.$areValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; }); it('should validate multiple ERC-1271 wallet signatures', async function () { @@ -209,7 +209,7 @@ describe('ERC7913Utils', function () { signatures.reverse(); } - await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + await expect(this.mock.$areValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; }); it('should validate multiple ERC-7913 signatures', async function () { @@ -226,13 +226,13 @@ describe('ERC7913Utils', function () { const signatures = signers.map(signer => signatureMap[ethers.hexlify(signer)]); - await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; + await expect(this.mock.$areValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.true; }); it('should return false if any signature is invalid', async function () { // Use two EOA signers but one signature is for the wrong message await expect( - this.mock.$isValidNSignaturesNow( + this.mock.$areValidNSignaturesNow( TEST_MESSAGE_HASH, [this.eoaSigner, this.eoaSigner2], [this.eoaSignature, this.wrongMessageSignature], @@ -251,12 +251,12 @@ describe('ERC7913Utils', function () { signatures.reverse(); } - await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.false; + await expect(this.mock.$areValidNSignaturesNow(TEST_MESSAGE_HASH, signers, signatures)).to.eventually.be.false; }); it('should return false if there are duplicate signers', async function () { await expect( - this.mock.$isValidNSignaturesNow( + this.mock.$areValidNSignaturesNow( TEST_MESSAGE_HASH, [this.eoaSigner, this.eoaSigner], // Same signer used twice [this.eoaSignature, this.eoaSignature], @@ -266,7 +266,7 @@ describe('ERC7913Utils', function () { it('should fail if signatures array length does not match signers array length', async function () { await expect( - this.mock.$isValidNSignaturesNow( + this.mock.$areValidNSignaturesNow( TEST_MESSAGE_HASH, [this.eoaSigner, this.eoaSigner2], [this.eoaSignature], // Missing one signature @@ -275,7 +275,7 @@ describe('ERC7913Utils', function () { }); it('should pass with empty arrays', async function () { - await expect(this.mock.$isValidNSignaturesNow(TEST_MESSAGE_HASH, [], [])).to.eventually.be.true; + await expect(this.mock.$areValidNSignaturesNow(TEST_MESSAGE_HASH, [], [])).to.eventually.be.true; }); }); }); From 03007fa1f4813614ff97915bb15431017cd22e24 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 18:11:55 -0600 Subject: [PATCH 41/90] Nits --- contracts/account/modules/ERC7579SignatureValidator.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/account/modules/ERC7579SignatureValidator.sol b/contracts/account/modules/ERC7579SignatureValidator.sol index b3de30c0..641c1e8c 100644 --- a/contracts/account/modules/ERC7579SignatureValidator.sol +++ b/contracts/account/modules/ERC7579SignatureValidator.sol @@ -125,8 +125,8 @@ contract ERC7579SignatureValidator is IERC7579Validator { _setSigner(msg.sender, signer_); } - /// @dev Internal version of {setSigner} that takes an `account` as argument. - function _setSigner(address account, bytes memory signer_) internal { + /// @dev Internal version of {setSigner} that takes an `account` as argument without validating `signer_`. + function _setSigner(address account, bytes memory signer_) internal virtual { _signers[account] = signer_; emit ERC7579SignatureValidatorSignerSet(account, signer_); } From df9974eae6a90e40461af51c4c0213c93b12ede2 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 18:34:59 -0600 Subject: [PATCH 42/90] up --- .../modules/ERC7579MultisigExecutor.sol | 35 ++++++-------- .../ERC7579MultisigWeightedExecutor.sol | 46 ++++++++++++------- 2 files changed, 43 insertions(+), 38 deletions(-) diff --git a/contracts/account/modules/ERC7579MultisigExecutor.sol b/contracts/account/modules/ERC7579MultisigExecutor.sol index 8110dc6f..5e59e22f 100644 --- a/contracts/account/modules/ERC7579MultisigExecutor.sol +++ b/contracts/account/modules/ERC7579MultisigExecutor.sol @@ -27,16 +27,17 @@ import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol */ contract ERC7579MultisigExecutor is ERC7579BaseExecutor { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + using ERC7913Utils for bytes32; using ERC7913Utils for bytes; /// @dev Emitted when signers are added. - event ERC7913SignersAdded(bytes[] indexed signers); + event ERC7913SignersAdded(address indexed account, bytes[] signers); /// @dev Emitted when signers are removed. - event ERC7913SignersRemoved(bytes[] indexed signers); + event ERC7913SignersRemoved(address indexed account, bytes[] signers); /// @dev Emitted when the threshold is updated. - event ERC7913ThresholdSet(uint256 threshold); + event ERC7913ThresholdSet(address indexed account, uint256 threshold); /// @dev The `signer` already exists. error ERC7579MultisigExecutorAlreadyExists(bytes signer); @@ -193,7 +194,7 @@ contract ERC7579MultisigExecutor is ERC7579BaseExecutor { require(signerSet.add(signer), ERC7579MultisigExecutorAlreadyExists(signer)); } - emit ERC7913SignersAdded(newSigners); + emit ERC7913SignersAdded(account, newSigners); } /// @dev Removes the `oldSigners` from the authorized signers for the account. @@ -206,14 +207,14 @@ contract ERC7579MultisigExecutor is ERC7579BaseExecutor { } _validateReachableThreshold(account); - emit ERC7913SignersRemoved(oldSigners); + emit ERC7913SignersRemoved(account, oldSigners); } /// @dev Sets the signatures `threshold` required to approve a multisignature operation. function _setThreshold(address account, uint256 newThreshold) internal virtual { _thresholdByAccount[account] = newThreshold; _validateReachableThreshold(account); - emit ERC7913ThresholdSet(newThreshold); + emit ERC7913ThresholdSet(account, newThreshold); } /// @dev Validates the current threshold is reachable with the number of {signers}. @@ -230,11 +231,12 @@ contract ERC7579MultisigExecutor is ERC7579BaseExecutor { * @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 `signerId` to ensure no duplicates and to optimize + * the verification process. The function will return `false` if the signers are not properly ordered. + * * Requirements: * - * - The `signers` and `signatures` arrays must be of the same length. - * - The signers must be ordered by their {signerId}. - * - The number of valid signatures must meet or exceed the threshold. + * * The `signatures` array must be at least the `signers` array's length. */ function _validateNSignatures( address account, @@ -242,24 +244,13 @@ contract ERC7579MultisigExecutor is ERC7579BaseExecutor { bytes[] memory signingSigners, bytes[] memory signatures ) internal view virtual returns (bool valid) { - bytes32 currentSignerId = bytes32(0); - uint256 signersLength = signingSigners.length; for (uint256 i = 0; i < signersLength; i++) { - // Signers must be ordered by id to ensure no duplicates - bytes memory signer = signingSigners[i]; - bytes32 id = signerId(signer); - - if ( - currentSignerId >= id || !isSigner(account, signer) || !signer.isValidSignatureNow(hash, signatures[i]) - ) { + if (!isSigner(account, signingSigners[i])) { return false; } - - currentSignerId = id; } - - return true; + return hash.areValidNSignaturesNow(signingSigners, signatures, signerId); } /** diff --git a/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol b/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol index 8082ecc1..f9060a78 100644 --- a/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol +++ b/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol @@ -114,27 +114,26 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { * * Requirements: * - * - `signers` and `weights` arrays must have the same length. - * - Each signer must exist in the set of authorized signers. - * - Each weight must be greater than 0. + * - `signers` and `weights` arrays must have the same length. Reverts with {ERC7579MultisigExecutorMismatchedLength} on mismatch. + * - Each signer must exist in the set of authorized signers. Reverts with {ERC7579MultisigExecutorNonexistentSigner} if not. + * - Each weight must be greater than 0. Reverts with {ERC7579MultisigExecutorInvalidWeight} if not. + * - See {_validateReachableThreshold} for the threshold validation. + * + * Emits {ERC7913SignerWeightChanged} for each signer. */ function _setSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) internal virtual { require(signers.length == newWeights.length, ERC7579MultisigExecutorMismatchedLength()); + uint256 oldWeight = _weightSigners(account, signers); - uint256 cachedTotalWeight = _totalWeightByAccount[account]; for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; uint256 newWeight = newWeights[i]; require(isSigner(account, signer), ERC7579MultisigExecutorNonexistentSigner(signer)); require(newWeight > 0, ERC7579MultisigExecutorInvalidWeight(signer, newWeight)); - - uint256 oldWeight = _signerWeight(account, signer); - _weightsByAccount[account][signerId(signer)] = newWeight; - cachedTotalWeight = (cachedTotalWeight + newWeight - oldWeight); - emit ERC7913SignerWeightChanged(account, signer, newWeight); } - _totalWeightByAccount[account] = cachedTotalWeight; + _unsafeSetSignerWeights(account, signers, newWeights); + _totalWeightByAccount[account] = _totalWeightByAccount[account] - oldWeight + _weightSigners(account, signers); _validateReachableThreshold(account); } @@ -150,13 +149,11 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { /// @dev Override to handle weight tracking during removal. See {ERC7579MultisigExecutor-_removeSigners}. function _removeSigners(address account, bytes[] memory oldSigners) internal virtual override { uint256 removedWeight = _weightSigners(account, oldSigners); - _totalWeightByAccount[account] -= removedWeight; - - for (uint256 i = 0; i < oldSigners.length; i++) { - delete _weightsByAccount[account][signerId(oldSigners[i])]; - emit ERC7913SignerWeightChanged(account, oldSigners[i], 0); + unchecked { + // Can't overflow. Invariant: sum(weights) >= threshold + _totalWeightByAccount[account] -= removedWeight; } - + _unsafeSetSignerWeights(account, oldSigners, new uint256[](oldSigners.length)); super._removeSigners(account, oldSigners); } @@ -198,4 +195,21 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { } 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 {ERC7913SignerWeightChanged} for each signer. + */ + function _unsafeSetSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) private { + for (uint256 i = 0; i < signers.length; i++) { + delete _weightsByAccount[account][signerId(signers[i])]; + emit ERC7913SignerWeightChanged(account, signers[i], newWeights[i]); + } + } } From 7be01b67d84adcaa71350fbb4451af3a5fc2ebc2 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 26 Apr 2025 18:36:49 -0600 Subject: [PATCH 43/90] Nit --- contracts/utils/cryptography/MultiSignerERC7913Weighted.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol index 74ac8e6b..e30f9654 100644 --- a/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -182,6 +182,8 @@ abstract contract MultiSignerERC7913Weighted is MultiSignerERC7913 { * Requirements: * * * The `newWeights` array must be at least as large as the `signers` array. Panics otherwise. + * + * Emits {ERC7913SignerWeightChanged} for each signer. */ function _unsafeSetSignerWeights(bytes[] memory signers, uint256[] memory newWeights) private { for (uint256 i = 0; i < signers.length; i++) { From c1e040095a3599bdf4231f9efa68de9a182285f1 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 27 Apr 2025 00:02:24 -0600 Subject: [PATCH 44/90] Iteration --- .../account/modules/ERC7579BaseExecutor.sol | 281 ------------- .../modules/ERC7579DelayedExecutor.sol | 394 ++++++++++++++++++ contracts/account/modules/ERC7579Executor.sol | 56 +++ .../modules/ERC7579MultisigExecutor.sol | 10 +- 4 files changed, 455 insertions(+), 286 deletions(-) delete mode 100644 contracts/account/modules/ERC7579BaseExecutor.sol create mode 100644 contracts/account/modules/ERC7579DelayedExecutor.sol create mode 100644 contracts/account/modules/ERC7579Executor.sol diff --git a/contracts/account/modules/ERC7579BaseExecutor.sol b/contracts/account/modules/ERC7579BaseExecutor.sol deleted file mode 100644 index 99978287..00000000 --- a/contracts/account/modules/ERC7579BaseExecutor.sol +++ /dev/null @@ -1,281 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import {IERC7579Module, MODULE_TYPE_EXECUTOR, IERC7579Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; -import {ERC7579Utils, Mode, CallType, ExecType, ModeSelector, ModePayload} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; - -/** - * @dev Base implementation for ERC-7579 executor modules that manages scheduling and executing - * delayed operations. This module enables time-delayed execution patterns for smart accounts. - * - * Once scheduled (see {schedule}), operations can only be executed after their specified delay - * period has elapsed (indicated during {onInstall}), creating a security window where suspicious - * operations can be monitored and potentially canceled (see {cancel}) before execution (see {execute}). - * - * Accounts can customize their delay periods with {setDelay}, Delay changes take effect after a - * transition period to prevent immediate security downgrades. - * - * IMPORTANT: This module assumes the {AccountERC7579} is the ultimate authority and does not restrict - * module uninstallation. An account can bypass the time-delay security by simply uninstalling - * the module. Consider adding safeguards in your Account implementation if uninstallation - * protection is required for your security model. - */ -abstract contract ERC7579BaseExecutor is IERC7579Module { - using Time for *; - - struct Schedule { - uint48 scheduledAt; - uint32 delay; - bool executed; - } - - mapping(address account => Time.Delay delay) private _accountDelays; - mapping(bytes32 operationId => Schedule) private _schedules; - - /// @dev Emitted when a new operation is scheduled. - event ERC7579ExecutorOperationScheduled( - address indexed account, - bytes32 indexed operationId, - Mode mode, - bytes executionCalldata, - bytes32 salt, - uint48 schedule - ); - - /// @dev Emitted when an operation is executed. - event ERC7579ExecutorOperationExecuted(address indexed account, bytes32 indexed operationId); - - /// @dev Emitted when a scheduled 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 Thrown when trying to execute an operation that is not scheduled. - error ERC7579BaseExecutorOperationNotScheduled(bytes32 operationId); - - /// @dev Thrown when trying to execute an operation before its execution time. - error ERC7579BaseExecutorOperationNotReady(bytes32 operationId, uint48 schedule); - - /// @dev Thrown when trying to schedule an operation that is already scheduled. - error ERC7579BaseExecutorOperationAlreadyScheduled(bytes32 operationId); - - /// @dev Thrown when trying to execute an operation that has already been executed. - error ERC7579BaseExecutorOperationAlreadyExecuted(bytes32 operationId); - - /// @inheritdoc IERC7579Module - function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { - return moduleTypeId == MODULE_TYPE_EXECUTOR; - } - - /** - * @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` can contain an `abi.encode(uint32(initialDelay))` value. - * The delay will be set to the maximum of this value and the minimum delay if provided. - * Otherwise, the delay will be set to the minimum delay. - */ - function onInstall(bytes calldata initData) public virtual { - uint32 minDelay = minimumDelay(); // Up to ~136 years. - uint32 delay = initData.length > 0 - ? uint32(Math.max(minDelay, abi.decode(initData, (uint32)))) // Safe downcast since both arguments are uint32 - : minDelay; - _accountDelays[msg.sender] = delay.toDelay(); - } - - /** - * @dev Cleans up account-specific state when the module is uninstalled from an account. - * - * 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, but module implementations may want to override this behavior to clear - * scheduled operations during uninstallation for their specific use cases. - */ - function onUninstall(bytes calldata) public virtual { - address account = msg.sender; - _accountDelays[account] = Time.toDelay(0); - } - - /// @dev Minimum delay for operations. Default for accounts that do not set a custom delay. - function minimumDelay() public view virtual returns (uint32) { - return 1 days; - } - - /// @dev Expiration time for operations. Defaults to `type(uint32).max` (no expiration). - function expiration() public view virtual returns (uint32) { - return type(uint32).max; - } - - /// @dev Delay for a specific account. If not set, returns the minimum delay. - function getDelay( - address account - ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { - (uint32 currentDelay, uint32 newDelay, uint48 effect) = _accountDelays[account].getFull(); - return ( - uint32(Math.max(currentDelay, minimumDelay())), // Safe downcast since both arguments are uint32 - newDelay, - effect - ); - } - - /** - * @dev Schedule for an operation. Returns default values if not set - * (i.e. `uint48(0)`, `uint32(0)`, `uint48(0)`, and `false`). - */ - function getSchedule( - address account, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt - ) public view virtual returns (uint48 scheduledAt, uint32 delay, uint48 timepoint, bool executed) { - return getSchedule(hashOperation(account, mode, executionCalldata, salt)); - } - - /// @dev Same as {getSchedule} but with the operation id. - function getSchedule( - bytes32 operationId - ) public view virtual returns (uint48 scheduledAt, uint32 delay, uint48 timepoint, bool executed) { - Schedule storage schedule_ = _schedules[operationId]; - scheduledAt = schedule_.scheduledAt; - delay = schedule_.delay; - timepoint = scheduledAt + delay; - return (scheduledAt, delay, timepoint, schedule_.executed); - } - - /// @dev Returns the operation id. - function hashOperation( - address account, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt - ) public view virtual returns (bytes32) { - return keccak256(abi.encode(account, mode, executionCalldata, salt)); - } - - /** - * @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 minimum delay, whichever is longer. This prevents immediate security downgrades. - * Can only be called by the account itself. - */ - function setDelay(uint32 newDelay) public virtual { - address account = msg.sender; - _setDelay(account, newDelay); - } - - /** - * @dev Schedules an operation to be executed after the account's delay period (see {getDelay}). - * Operations are uniquely identified by the combination of `mode`, `executionCalldata`, and `salt`. - * Can only be called by the account itself to schedule its own operations. - * - * Requirements: - * - * * Operation must not have been scheduled already. Reverts with {ERC7579BaseExecutorOperationAlreadyScheduled} otherwise. - */ - function schedule(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - _schedule(msg.sender, mode, executionCalldata, salt); - } - - /** - * @dev Executes a previously scheduled operation if its delay period has elapsed (see {getDelay}). - * Returns the result data from the executed operation. - * - * Requirements: - * - * * Operation must have been scheduled. Reverts with {ERC7579BaseExecutorOperationNotScheduled} otherwise. - * * Operation must not have been executed yet. Reverts with {ERC7579BaseExecutorOperationAlreadyExecuted} otherwise. - * * Operation must be ready for execution. Reverts with {ERC7579BaseExecutorOperationNotReady} otherwise. - * - * The operation must be scheduled and not already executed. - * - * NOTE: Anyone can trigger execution once the timepoint has been reached. - */ - function execute( - address account, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt - ) public virtual returns (bytes[] memory returnData) { - return _execute(account, mode, executionCalldata, salt); - } - - /** - * @dev Cancels a previously scheduled operation. Can only be called by the account that scheduled the operation. - * - * Requirements: - * - * * Operation must have been scheduled. Reverts with {ERC7579BaseExecutorOperationNotScheduled} otherwise. - */ - function cancel(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - _cancel(msg.sender, mode, executionCalldata, salt); - } - - /** - * @dev Internal implementation for setting an account's delay. - * - * Updates the account's delay configuration and emits an event with the - * new delay and when it will take effect. - */ - function _setDelay(address account, uint32 newDelay) internal virtual { - uint48 effect; - (_accountDelays[account], effect) = _accountDelays[account].withUpdate(newDelay, minimumDelay()); - emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); - } - - /// @dev Internal version of {schedule} that takes an `account` address as an argument. - function _schedule( - address account, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt - ) internal virtual returns (bytes32 operationId, Schedule memory schedule_) { - bytes32 id = hashOperation(account, mode, executionCalldata, salt); - (uint32 delay, , ) = getDelay(account); - - uint48 timepoint = Time.timestamp() + delay; - require(timepoint == 0, ERC7579BaseExecutorOperationAlreadyScheduled(id)); - - schedule_ = Schedule(Time.timestamp(), delay, false); - _schedules[id] = schedule_; - - emit ERC7579ExecutorOperationScheduled(account, id, mode, executionCalldata, salt, timepoint); - return (id, schedule_); - } - - /// @dev Internal version of {execute}. - function _execute( - address account, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt - ) internal virtual returns (bytes[] memory returnData) { - bytes32 id = hashOperation(account, mode, executionCalldata, salt); - (uint48 scheduledAt, , uint48 timepoint, bool executed) = getSchedule(id); - - require(scheduledAt != 0, ERC7579BaseExecutorOperationNotScheduled(id)); - require(!executed, ERC7579BaseExecutorOperationAlreadyExecuted(id)); - require(Time.timestamp() >= timepoint, ERC7579BaseExecutorOperationNotReady(id, timepoint)); - - _schedules[id].executed = true; // Mark the operation as executed - emit ERC7579ExecutorOperationExecuted(account, id); - return IERC7579Execution(account).executeFromExecutor(Mode.unwrap(mode), executionCalldata); - } - - /// @dev Internal version of {cancel} that takes an `account` address as an argument. - function _cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - bytes32 id = hashOperation(account, mode, executionCalldata, salt); - (uint48 scheduledAt, , , bool executed) = getSchedule(id); - - require(scheduledAt != 0, ERC7579BaseExecutorOperationNotScheduled(id)); - require(!executed, ERC7579BaseExecutorOperationAlreadyExecuted(id)); - - _schedules[id].scheduledAt = 0; - _schedules[id].delay = 0; - _schedules[id].executed = false; - emit ERC7579ExecutorOperationCanceled(account, id); - } -} diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol new file mode 100644 index 00000000..e6c03364 --- /dev/null +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; +import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.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. + * + * Once scheduled (see {schedule}), operations can only be executed after their specified delay + * period has elapsed (indicated during {onInstall}), creating a security window where suspicious + * operations can be monitored and potentially canceled (see {cancel}) before execution (see {execute}). + * + * Accounts can customize their delay periods with {setDelay}, Delay changes take effect after a + * transition period to prevent immediate security downgrades. + * + * Operations have an expiration mechanism that prevents them from being executed after a certain + * time has passed. It can be customized by overriding the {expiration} function and defaults to + * `type(uint32).max` (no expiration). + * + * IMPORTANT: This module assumes the {AccountERC7579} is the ultimate authority and does not restrict + * module uninstallation. An account can bypass the time-delay security by simply uninstalling + * the module. Consider adding safeguards in your Account implementation if uninstallation + * protection is required for your security model. + */ +abstract contract ERC7579DelayedExecutor is ERC7579Executor { + using Time for *; + + uint32 private constant NO_DELAY = type(uint32).max; // Sentinel value for no delay + uint32 private constant EXECUTED = type(uint32).max - 1; // Sentinel value for no delay + + // Invariant `delay` <= `expiration` < `type(uint32).max - 1` (for NO_DELAY and EXECUTED) + struct Schedule { + uint48 scheduledAt; // The time when the operation was scheduled + uint32 delay; // Time after the operation becomes executable + uint32 expiration; // Time after the operation expires + } + + struct ExecutionConfig { + Time.Delay delay; + Time.Delay expiration; + } + + mapping(address account => ExecutionConfig) private _config; + mapping(bytes32 operationId => Schedule) private _schedules; + + /// @dev Emitted when a new operation is scheduled. + event ERC7579ExecutorOperationScheduled( + address indexed account, + bytes32 indexed operationId, + Mode mode, + bytes executionCalldata, + bytes32 salt, + uint48 schedule + ); + + /// @dev Emitted when a scheduled 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, uint48 effectTime); + + /// @dev Thrown when the account already installed the module. + error ERC7579ExecutorAlreadyInstalled(address account); + + /// @dev Thrown when trying to execute an operation that is not scheduled. + error ERC7579ExecutorOperationNotScheduled(bytes32 operationId); + + /// @dev Thrown when trying to execute an operation before its execution time. + error ERC7579ExecutorOperationNotReady(bytes32 operationId, uint48 schedule); + + /// @dev Thrown when trying to schedule an operation that is already scheduled. + error ERC7579ExecutorOperationAlreadyScheduled(bytes32 operationId); + + /// @dev Thrown when trying to execute an operation that has already been executed. + error ERC7579ExecutorOperationAlreadyExecuted(bytes32 operationId); + + /// @dev Thrown when trying to execute an operation that has expired. + error ERC7579ExecutorOperationExpired(bytes32 operationId, uint48 expiresAt); + + /// @dev Minimum delay for operations. Default for accounts that do not set a custom delay. + function minimumDelay() public view virtual returns (uint32) { + return 1 days; // Up to ~136 years + } + + /// @dev Minimum expiration for operations. Default for accounts that do not set a custom expiration. + function minimumExpiration() public view virtual returns (uint32) { + return 365 days; // Up to ~136 years + } + + /// @dev Delay for a specific account. If not set, returns the minimum delay. + function getDelay( + address account + ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { + (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].delay.getFull(); + return ( + // Safe downcast since both arguments are uint32 + uint32(Math.ternary(_isDelayUninstalled(currentDelay), 0, Math.max(currentDelay, minimumDelay()))), + newDelay, + effect + ); + } + + /// @dev Delay for a specific account. If not set, returns the minimum delay. + function getExpiration( + address account + ) public view virtual returns (uint32 expiration, uint32 pendingExpiration, uint48 effectTime) { + (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].expiration.getFull(); + return ( + // Safe downcast since both arguments are uint32 + uint32(Math.ternary(_isDelayUninstalled(currentDelay), 0, Math.max(currentDelay, minimumExpiration()))), + newDelay, + effect + ); + } + + /** + * @dev Schedule for an operation. Returns default values if not set + * (i.e. `uint48(0)`, `uint48(0)`, `uint48(0)`, and `false`). + */ + function getSchedule( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt, bool executed) { + return getSchedule(hashOperation(account, mode, executionCalldata, salt)); + } + + /// @dev Same as {getSchedule} but with the operation id. + function getSchedule( + bytes32 operationId + ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt, bool executed) { + Schedule storage schedule_ = _schedules[operationId]; + scheduledAt = schedule_.scheduledAt; + uint32 delay = schedule_.delay; + return (scheduledAt, scheduledAt + delay, scheduledAt + schedule_.expiration, delay == EXECUTED); + } + + /// @dev Returns the operation id. + function hashOperation( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public view virtual returns (bytes32) { + return keccak256(abi.encode(account, mode, executionCalldata, salt)); + } + + /** + * @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` can contain an `abi.encode(uint32(initialDelay))` value. + * The delay will be set to the maximum of this value and the minimum delay if provided. + * Otherwise, the delay will be set to the minimum delay. + * + * Requirements: + * + * * The account must not have the module installed already. See {ERC7579ExecutorAlreadyInstalled}. + * * The delay must be different to `type(uint32).max` used as a sentinel value for no delay. + * + * IMPORTANT: A delay will be set for the calling account. In case the account calls this function + * directly, the delay will be set to the provided data even if the account didn't track + * the module's installation. Future installations will revert. + */ + function onInstall(bytes calldata initData) public virtual { + (uint32 currentDelay, , ) = getDelay(msg.sender); + require(_isDelayUninstalled(currentDelay), ERC7579ExecutorAlreadyInstalled(msg.sender)); + (uint32 delay, uint32 expiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) : (0, 0); + _setDelay(msg.sender, uint32(Math.max(minimumDelay(), delay))); // Safe downcast since both arguments are uint32 + _setExpiration(msg.sender, uint32(Math.max(minimumExpiration(), expiration))); // Safe downcast since both arguments are uint32 + } + + /** + * @dev Cleans up the {getDelay} and {getExpiration} values by scheduling them to `0` + * and respecting the previous delay and expiration values. Do not consider {minimumDelay} and + * {minimumExpiration} for scheduling. + * + * 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, 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 { + _unsafeSetDelay(msg.sender, 0, 0); + _unsafeSetExpiration(msg.sender, 0, 0); + } + + /** + * @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 minimum delay, 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); + } + + /** + * @dev Allows an account to update its execution expiration (see {getExpiration}). + * + * The new expiration will take effect after a transition period defined by the current expiration + * or minimum delay, whichever is longer. This prevents immediate security downgrades. + * Can only be called by the account itself. + */ + 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 `mode`, `executionCalldata`, and `salt`. + * Can only be called by the account itself to schedule its own operations. See {_schedule}. + */ + function schedule(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + _schedule(msg.sender, mode, executionCalldata, salt); + } + + /** + * @dev Cancels a previously scheduled operation. Can only be called by the account that + * scheduled the operation. See {_cancel}. + */ + function cancel(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + _cancel(msg.sender, mode, executionCalldata, salt); + } + + /** + * @dev Internal implementation for setting an account's delay. See {getDelay}. + * + * Emits an {ERC7579ExecutorDelayUpdated} event. + * + * NOTE: The delay is set to `type(uint32).max` if the new delay is `0`. This is a + * reserved value to indicate that the module is not installed. + */ + function _setDelay(address account, uint32 newDelay) internal virtual { + _unsafeSetDelay( + account, + uint32(Math.ternary(newDelay == 0, NO_DELAY, newDelay)), // Safe downcast since both arguments are uint32 + minimumDelay() + ); + } + + /** + * @dev Internal implementation for setting an account's expiration. See {getExpiration}. + * + * Emits an {ERC7579ExecutorExpirationUpdated} event. + */ + function _setExpiration(address account, uint32 newExpiration) internal virtual { + _unsafeSetExpiration(account, newExpiration, minimumExpiration()); + } + + /// @dev Version of {_setDelay} without `type(uint32).max` check and with a custom minimum setback. + function _unsafeSetDelay(address account, uint32 newDelay, uint32 minSetback) internal virtual { + (uint32 delay, uint32 pendingDelay, uint48 effectTime) = getDelay(account); + uint48 effect; + (_config[account].delay, effect) = Time.pack(delay, pendingDelay, effectTime).withUpdate(newDelay, minSetback); + emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); + } + + /// @dev Version of {_setExpiration} without `type(uint32).max` check and with a custom minimum setback. + function _unsafeSetExpiration(address account, uint32 newExpiration, uint32 minSetback) internal virtual { + (uint32 expiration, uint32 pendingExpiration, uint48 effectTime) = getExpiration(account); + uint48 effect; + (_config[account].expiration, effect) = Time.pack(expiration, pendingExpiration, effectTime).withUpdate( + newExpiration, + minSetback + ); + emit ERC7579ExecutorExpirationUpdated(account, newExpiration, effect); + } + + /** + * @dev Internal version of {schedule} that takes an `account` address as an argument. + * + * Requirements: + * + * * Operation must not have been scheduled already. See {ERC7579ExecutorOperationAlreadyScheduled}. + * + * Emits an {ERC7579ExecutorOperationScheduled} event. + */ + function _schedule( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal virtual returns (bytes32 operationId, Schedule memory schedule_) { + bytes32 id = hashOperation(account, mode, executionCalldata, salt); + (uint32 delay, , ) = getDelay(account); + (uint32 expiration, , ) = getExpiration(account); + + uint48 timepoint = Time.timestamp() + delay; + require(timepoint == 0, ERC7579ExecutorOperationAlreadyScheduled(id)); + + _schedules[id].scheduledAt = timepoint; + _schedules[id].delay = delay; + _schedules[id].expiration = expiration; + + emit ERC7579ExecutorOperationScheduled(account, id, mode, executionCalldata, salt, timepoint); + return (id, schedule_); + } + + /** + * @dev See {ERC7579Executor-_execute}. + * + * Requirements: + * + * * Operation must have been scheduled. Reverts with {ERC7579ExecutorOperationNotScheduled} otherwise. + * * Operation must not have been executed yet. Reverts with {ERC7579ExecutorOperationAlreadyExecuted} otherwise. + * * Operation must be ready for execution. Reverts with {ERC7579ExecutorOperationNotReady} otherwise. + * * Operation must not have expired. Reverts with {ERC7579ExecutorOperationExpired} otherwise. + * + * NOTE: Anyone can trigger execution once the the execution delay has passed. See {getSchedule}. + */ + function _execute( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal virtual override returns (bytes[] memory returnData) { + bytes32 id = hashOperation(account, mode, executionCalldata, salt); + (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt, bool executed) = getSchedule(id); + + require(scheduledAt != 0, ERC7579ExecutorOperationNotScheduled(id)); + require(!executed, ERC7579ExecutorOperationAlreadyExecuted(id)); + require(Time.timestamp() >= executableAt, ERC7579ExecutorOperationNotReady(id, executableAt)); + require(Time.timestamp() < expiresAt, ERC7579ExecutorOperationExpired(id, expiresAt)); + + _config[account].delay = EXECUTED.toDelay(); // Mark the operation as executed + + return super._execute(account, mode, executionCalldata, salt); + } + + /** + * @dev Internal version of {cancel} that takes an `account` address as an argument. + * + * [NOTE] + * ==== + * Expired operations can be canceled, which allows for rescheduling. Consider + * overriding this behavior in derived contracts if you want to prevent rescheduling + * of expired operations. + * + * ```solidity + * function _cancel( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt + * ) internal virtual override { + * bytes32 id = hashOperation(account, mode, executionCalldata, salt); + * (, , , uint48 expiresAt, ) = getSchedule(id); + * require(expiresAt == 0, ERC7579ExecutorOperationExpired(id, expiresAt)); + * super._cancel(account, mode, executionCalldata, salt); + * } + * ``` + * ==== + * + * Requirements: + * + * * Operation must have been scheduled. Reverts with {ERC7579ExecutorOperationNotScheduled} otherwise. + * * Operation must not have been executed yet. Reverts with {ERC7579ExecutorOperationAlreadyExecuted} otherwise. + * + * Emits an {ERC7579ExecutorOperationCanceled} event. + */ + function _cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) internal virtual { + bytes32 id = hashOperation(account, mode, executionCalldata, salt); + (uint48 scheduledAt, , , bool executed) = getSchedule(id); + + require(scheduledAt != 0, ERC7579ExecutorOperationNotScheduled(id)); + require(!executed, ERC7579ExecutorOperationAlreadyExecuted(id)); + + _schedules[id].scheduledAt = 0; + _schedules[id].delay = 0; + _schedules[id].expiration = 0; + // _schedules[id].executed defaults to false + emit ERC7579ExecutorOperationCanceled(account, id); + } + + /// @dev Checks whether the module is uninstalled depending on the account's `delay` value. + function _isDelayUninstalled(uint32 delay) private pure returns (bool) { + return delay == 0; + } +} diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol new file mode 100644 index 00000000..e4dac014 --- /dev/null +++ b/contracts/account/modules/ERC7579Executor.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IERC7579Module, MODULE_TYPE_EXECUTOR, IERC7579Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.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. Each execution emits an event for transparency + * and auditability. + * + * 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, Mode mode, bytes callData, bytes32 salt); + + /// @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. + * See {_execute} for requirements. + */ + function execute( + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public virtual returns (bytes[] memory returnData) { + return _execute(msg.sender, mode, executionCalldata, salt); + } + + /** + * @dev Internal version of {execute}. Emits {ERC7579ExecutorOperationExecuted} event. + * + * Requirements: + * + * * The `account` must implement the {IERC7579Execution-executeFromExecutor} function. + */ + function _execute( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal virtual returns (bytes[] memory returnData) { + emit ERC7579ExecutorOperationExecuted(account, mode, executionCalldata, salt); + return IERC7579Execution(account).executeFromExecutor(Mode.unwrap(mode), executionCalldata); + } +} diff --git a/contracts/account/modules/ERC7579MultisigExecutor.sol b/contracts/account/modules/ERC7579MultisigExecutor.sol index 5e59e22f..641b5686 100644 --- a/contracts/account/modules/ERC7579MultisigExecutor.sol +++ b/contracts/account/modules/ERC7579MultisigExecutor.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import {ERC7579BaseExecutor} from "./ERC7579BaseExecutor.sol"; +import {ERC7579DelayedExecutor} from "./ERC7579DelayedExecutor.sol"; import {ERC7913Utils} from "../../utils/cryptography/ERC7913Utils.sol"; import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; /** - * @dev Implementation of {ERC7579BaseExecutor} that uses ERC-7913 signers for multisignature + * @dev Implementation of {ERC7579DelayedExecutor} that uses ERC-7913 signers for multisignature * operation scheduling. * * This module extends the base time-delayed executor with multisignature capabilities, @@ -25,7 +25,7 @@ import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol * after obtaining approval from a set number of signers (e.g., 3-of-5 guardians), * and then execute them after the time delay has passed. */ -contract ERC7579MultisigExecutor is ERC7579BaseExecutor { +contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; using ERC7913Utils for bytes32; using ERC7913Utils for bytes; @@ -59,7 +59,7 @@ contract ERC7579MultisigExecutor is ERC7579BaseExecutor { /** * @dev Sets up the module's initial configuration when installed by an account. - * See {ERC7579BaseExecutor-onInstall}. Besides the delay setup, the `initdata` can + * See {ERC7579DelayedExecutor-onInstall}. Besides the delay setup, the `initdata` can * include `signers` and `threshold`. * * The initData should be encoded as: @@ -83,7 +83,7 @@ contract ERC7579MultisigExecutor is ERC7579BaseExecutor { * @dev Cleans up module's configuration when uninstalled from an account. * Clears all signers and resets the threshold. * - * See {ERC7579BaseExecutor-onUninstall}. + * See {ERC7579DelayedExecutor-onUninstall}. * * WARNING: This function has unbounded gas costs and may become uncallable if the set grows too large. * See {EnumerableSetExtended-clear}. From 97a639c73e45c2e25b5ab27596c07e54fdfcab22 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 27 Apr 2025 09:57:15 -0600 Subject: [PATCH 45/90] up --- contracts/account/modules/ERC7579MultisigWeightedExecutor.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol b/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol index f9060a78..f6a62e91 100644 --- a/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol +++ b/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol @@ -143,7 +143,7 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { */ function _addSigners(address account, bytes[] memory newSigners) internal virtual override { super._addSigners(account, newSigners); - _totalWeightByAccount[account] += newSigners.length; // Default weight of 1 per signer + _totalWeightByAccount[account] += newSigners.length; // Default weight of 1 per signer. } /// @dev Override to handle weight tracking during removal. See {ERC7579MultisigExecutor-_removeSigners}. From 32051db0cd086160b4c082691deb96b4cf655171 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 7 May 2025 20:00:24 -0600 Subject: [PATCH 46/90] Remove unnecessary fil --- test/account/modules/ERC7579.behavior.js | 69 ------------------------ 1 file changed, 69 deletions(-) delete mode 100644 test/account/modules/ERC7579.behavior.js diff --git a/test/account/modules/ERC7579.behavior.js b/test/account/modules/ERC7579.behavior.js deleted file mode 100644 index 528a10b1..00000000 --- a/test/account/modules/ERC7579.behavior.js +++ /dev/null @@ -1,69 +0,0 @@ -const { ethers } = require('hardhat'); -const { expect } = require('chai'); -const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('@openzeppelin/contracts/test/helpers/erc4337'); - -function shouldBehaveLikeERC7579Module() { - describe('behaves like ERC7579Module', function () { - it('identifies its module type correctly', async function () { - await expect(this.mock.isModuleType(this.moduleType)).to.eventually.be.true; - await expect(this.mock.isModuleType(999)).to.eventually.be.false; // Using random unassigned module type - }); - - it('handles installation and uninstallation', async function () { - await expect(this.mockFromAccount.onInstall(this.installData || '0x')).to.not.be.reverted; - await expect(this.mockFromAccount.onUninstall(this.uninstallData || '0x')).to.not.be.reverted; - }); - }); -} - -function shouldBehaveLikeERC7579Validator() { - describe('behaves like ERC7579Validator', function () { - const MAGIC_VALUE = '0x1626ba7e'; - const INVALID_VALUE = '0xffffffff'; - - beforeEach(async function () { - await this.mockFromAccount.onInstall(this.installData); - }); - - describe('validateUserOp', function () { - it('returns SIG_VALIDATION_SUCCESS when signature is valid', async function () { - const userOp = await this.mockAccount.createUserOp(this.userOp).then(op => this.signUserOp(op)); - await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( - SIG_VALIDATION_SUCCESS, - ); - }); - - it('returns SIG_VALIDATION_FAILURE when signature is invalid', async function () { - const userOp = await this.mockAccount.createUserOp(this.userOp); - userOp.signature = this.invalidSignature || '0x00'; - await expect(this.mockFromAccount.validateUserOp(userOp.packed, userOp.hash())).to.eventually.equal( - SIG_VALIDATION_FAILURE, - ); - }); - }); - - describe('isValidSignatureWithSender', function () { - it('returns magic value for valid signature', async function () { - const message = 'Hello, world!'; - const hash = ethers.hashMessage(message); - const signature = await this.signer.signMessage(message); - await expect(this.mockFromAccount.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( - MAGIC_VALUE, - ); - }); - - it('returns failure value for invalid signature', async function () { - const hash = ethers.hashMessage('Hello, world!'); - const signature = this.invalidSignature || '0x00'; - await expect(this.mock.isValidSignatureWithSender(this.other, hash, signature)).to.eventually.equal( - INVALID_VALUE, - ); - }); - }); - }); -} - -module.exports = { - shouldBehaveLikeERC7579Module, - shouldBehaveLikeERC7579Validator, -}; From 6b6bb4181954676af3cf08f7a098be3bbcb6a4b5 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 8 May 2025 00:33:07 -0600 Subject: [PATCH 47/90] Iterate --- .../modules/ERC7579DelayedExecutor.sol | 390 +++++++++++------- contracts/account/modules/ERC7579Executor.sol | 43 +- ...ltisigExecutor.sol => ERC7579Multisig.sol} | 162 +++++--- ...ecutor.sol => ERC7579MultisigWeighted.sol} | 71 ++-- 4 files changed, 397 insertions(+), 269 deletions(-) rename contracts/account/modules/{ERC7579MultisigExecutor.sol => ERC7579Multisig.sol} (60%) rename contracts/account/modules/{ERC7579MultisigWeightedExecutor.sol => ERC7579MultisigWeighted.sol} (74%) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index e6c03364..99266854 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; +import {IERC7579ModuleConfig, MODULE_TYPE_EXECUTOR} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; import {ERC7579Executor} from "./ERC7579Executor.sol"; /** @@ -29,23 +30,30 @@ import {ERC7579Executor} from "./ERC7579Executor.sol"; abstract contract ERC7579DelayedExecutor is ERC7579Executor { using Time for *; - uint32 private constant NO_DELAY = type(uint32).max; // Sentinel value for no delay - uint32 private constant EXECUTED = type(uint32).max - 1; // Sentinel value for no delay - // Invariant `delay` <= `expiration` < `type(uint32).max - 1` (for NO_DELAY and EXECUTED) struct Schedule { + // 1 slot = 48 + 32 + 32 + 1 + 1 = 114 bits ~ 14 bytes uint48 scheduledAt; // The time when the operation was scheduled - uint32 delay; // Time after the operation becomes executable - uint32 expiration; // Time after the operation expires + 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; - Time.Delay expiration; + uint32 expiration; } - mapping(address account => ExecutionConfig) private _config; - mapping(bytes32 operationId => Schedule) private _schedules; + enum OperationState { + Unknown, + Scheduled, + Ready, + Expired, + Executed, + Canceled + } /// @dev Emitted when a new operation is scheduled. event ERC7579ExecutorOperationScheduled( @@ -57,32 +65,126 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { uint48 schedule ); - /// @dev Emitted when a scheduled operation is canceled. + /// @dev Emitted when a new operation is canceled. event ERC7579ExecutorOperationCanceled(address indexed account, bytes32 indexed operationId); + /// @dev Emitted when a new operation is executed. + event ERC7579ExecutorOperationExecuted(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, uint48 effectTime); + event ERC7579ExecutorExpirationUpdated(address indexed account, uint32 newExpiration); - /// @dev Thrown when the account already installed the module. - error ERC7579ExecutorAlreadyInstalled(address account); + /** + * @dev The current state of a operation is not the expected. The `expectedStates` is a bitmap with the + * bits enabled for each ProposalState 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 Thrown when trying to execute an operation that is not scheduled. - error ERC7579ExecutorOperationNotScheduled(bytes32 operationId); + /// @dev The operation is not authorized to be canceled. + error ERC7579UnauthorizedCancellation(); - /// @dev Thrown when trying to execute an operation before its execution time. - error ERC7579ExecutorOperationNotReady(bytes32 operationId, uint48 schedule); + /// @dev The operation is not authorized to be scheduled. + error ERC7579UnauthorizedSchedule(); - /// @dev Thrown when trying to schedule an operation that is already scheduled. - error ERC7579ExecutorOperationAlreadyScheduled(bytes32 operationId); + mapping(address account => ExecutionConfig) private _config; + mapping(bytes32 operationId => Schedule) private _schedules; - /// @dev Thrown when trying to execute an operation that has already been executed. - error ERC7579ExecutorOperationAlreadyExecuted(bytes32 operationId); + /// @dev Current state of an operation. + function state( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public view returns (OperationState) { + return state(hashOperation(account, mode, executionCalldata, salt)); + } + + /// @dev Same as {state}, but for a specific operation. + function state(bytes32 operationId) public view returns (OperationState) { + Schedule storage sched = _schedules[operationId]; + if (sched.scheduledAt == 0) return OperationState.Unknown; + if (sched.canceled) return OperationState.Canceled; + if (sched.executed) return OperationState.Executed; + if (block.timestamp < sched.scheduledAt + sched.executableAfter) return OperationState.Scheduled; + if (block.timestamp > sched.scheduledAt + sched.expiresAfter) return OperationState.Expired; + return OperationState.Ready; + } - /// @dev Thrown when trying to execute an operation that has expired. - error ERC7579ExecutorOperationExpired(bytes32 operationId, uint48 expiresAt); + /// @dev See {ERC7579Executor-canExecute}. Allows anyone to execute an operation if it's {OperationState-Ready}. + function canExecute( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public view virtual override returns (bool) { + bytes32 id = hashOperation(account, mode, executionCalldata, salt); + return state(id) == OperationState.Ready || super.canExecute(account, mode, executionCalldata, salt); + } + + /** + * @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: + * + * ``` + * function canCancel( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt + * ) public view virtual returns (bool) { + * bool isAuthorized = ...; // custom logic to check authorization + * return isAuthorized || super.canCancel(account, mode, executionCalldata, salt); + * } + *``` + */ + function canCancel( + address account, + Mode /* mode */, + bytes calldata /* executionCalldata */, + bytes32 /* salt */ + ) public view virtual returns (bool) { + return account == msg.sender; + } + + /** + * @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: + * + * ``` + * function canSchedule( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt + * ) public view virtual returns (bool) { + * bool isAuthorized = ...; // custom logic to check authorization + * return isAuthorized || super.canSchedule(account, mode, executionCalldata, salt); + * } + *``` + */ + function canSchedule( + address account, + Mode /* mode */, + bytes calldata /* executionCalldata */, + bytes32 /* salt */ + ) public view virtual returns (bool) { + return account == msg.sender; + } /// @dev Minimum delay for operations. Default for accounts that do not set a custom delay. function minimumDelay() public view virtual returns (uint32) { @@ -99,48 +201,42 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { address account ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].delay.getFull(); + bool installed = IERC7579ModuleConfig(msg.sender).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); return ( // Safe downcast since both arguments are uint32 - uint32(Math.ternary(_isDelayUninstalled(currentDelay), 0, Math.max(currentDelay, minimumDelay()))), + uint32(Math.ternary(installed, 0, Math.max(currentDelay, minimumDelay()))), newDelay, effect ); } - /// @dev Delay for a specific account. If not set, returns the minimum delay. - function getExpiration( - address account - ) public view virtual returns (uint32 expiration, uint32 pendingExpiration, uint48 effectTime) { - (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].expiration.getFull(); - return ( - // Safe downcast since both arguments are uint32 - uint32(Math.ternary(_isDelayUninstalled(currentDelay), 0, Math.max(currentDelay, minimumExpiration()))), - newDelay, - effect - ); + /// @dev Expiration delay for account operations. If not set, returns the minimum delay. + function getExpiration(address account) public view virtual returns (uint32 expiration) { + bool installed = IERC7579ModuleConfig(msg.sender).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + // Safe downcast since both arguments are uint32 + return uint32(Math.ternary(!installed, 0, Math.max(_config[account].expiration, minimumExpiration()))); } - /** - * @dev Schedule for an operation. Returns default values if not set - * (i.e. `uint48(0)`, `uint48(0)`, `uint48(0)`, and `false`). - */ + /// @dev Schedule for an operation. Returns default values if not set (i.e. `uint48(0)`, `uint48(0)`, `uint48(0)`). function getSchedule( address account, Mode mode, bytes calldata executionCalldata, bytes32 salt - ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt, bool executed) { + ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt) { return getSchedule(hashOperation(account, mode, executionCalldata, salt)); } /// @dev Same as {getSchedule} but with the operation id. function getSchedule( bytes32 operationId - ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt, bool executed) { - Schedule storage schedule_ = _schedules[operationId]; - scheduledAt = schedule_.scheduledAt; - uint32 delay = schedule_.delay; - return (scheduledAt, scheduledAt + delay, scheduledAt + schedule_.expiration, delay == EXECUTED); + ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt) { + scheduledAt = _schedules[operationId].scheduledAt; + return ( + scheduledAt, + scheduledAt + _schedules[operationId].executableAfter, + scheduledAt + _schedules[operationId].expiresAfter + ); } /// @dev Returns the operation id. @@ -157,45 +253,27 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * @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` can contain an `abi.encode(uint32(initialDelay))` value. + * 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 the minimum delay. * - * Requirements: + * Behaves as a no-op if the module is already installed. * - * * The account must not have the module installed already. See {ERC7579ExecutorAlreadyInstalled}. - * * The delay must be different to `type(uint32).max` used as a sentinel value for no delay. + * Requirements: * - * IMPORTANT: A delay will be set for the calling account. In case the account calls this function - * directly, the delay will be set to the provided data even if the account didn't track - * the module's installation. Future installations will revert. + * * The account (i.e `msg.sender`) must implement the {IERC7579ModuleConfig} interface. + * * The {IERC7579ModuleConfig-isModuleInstalled} function must return not revert. + * * `initData` must be empty or decode correctly to `(uint32, uint32)`. */ function onInstall(bytes calldata initData) public virtual { - (uint32 currentDelay, , ) = getDelay(msg.sender); - require(_isDelayUninstalled(currentDelay), ERC7579ExecutorAlreadyInstalled(msg.sender)); - (uint32 delay, uint32 expiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) : (0, 0); - _setDelay(msg.sender, uint32(Math.max(minimumDelay(), delay))); // Safe downcast since both arguments are uint32 - _setExpiration(msg.sender, uint32(Math.max(minimumExpiration(), expiration))); // Safe downcast since both arguments are uint32 - } - - /** - * @dev Cleans up the {getDelay} and {getExpiration} values by scheduling them to `0` - * and respecting the previous delay and expiration values. Do not consider {minimumDelay} and - * {minimumExpiration} for scheduling. - * - * 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, 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 { - _unsafeSetDelay(msg.sender, 0, 0); - _unsafeSetExpiration(msg.sender, 0, 0); + bool installed = IERC7579ModuleConfig(msg.sender).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + if (!installed) { + (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 + ? abi.decode(initData, (uint32, uint32)) + : (0, 0); + _setDelay(msg.sender, initialDelay); + _setExpiration(msg.sender, initialExpiration); + } } /** @@ -209,13 +287,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { _setDelay(msg.sender, newDelay); } - /** - * @dev Allows an account to update its execution expiration (see {getExpiration}). - * - * The new expiration will take effect after a transition period defined by the current expiration - * or minimum delay, whichever is longer. This prevents immediate security downgrades. - * Can only be called by the account itself. - */ + /// @dev Allows an account to update its execution expiration (see {getExpiration}). function setExpiration(uint32 newExpiration) public virtual { _setExpiration(msg.sender, newExpiration); } @@ -223,9 +295,10 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /** * @dev Schedules an operation to be executed after the account's delay period (see {getDelay}). * Operations are uniquely identified by the combination of `mode`, `executionCalldata`, and `salt`. - * Can only be called by the account itself to schedule its own operations. See {_schedule}. + * See {canSchedule} for authorization checks. */ function schedule(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + require(canSchedule(msg.sender, mode, executionCalldata, salt), ERC7579UnauthorizedSchedule()); _schedule(msg.sender, mode, executionCalldata, salt); } @@ -233,24 +306,38 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * @dev Cancels a previously scheduled operation. Can only be called by the account that * scheduled the operation. See {_cancel}. */ - function cancel(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - _cancel(msg.sender, mode, executionCalldata, salt); + function cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + require(canCancel(account, mode, executionCalldata, salt), ERC7579UnauthorizedCancellation()); + _cancel(account, mode, executionCalldata, salt); + } + + /** + * @dev Cleans up the {getDelay} and {getExpiration} values by scheduling them to `0` + * and respecting the previous delay and expiration values. Do not consider {minimumDelay} and + * {minimumExpiration} for scheduling. + * + * 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, 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 { + _unsafeSetDelay(msg.sender, 0, 0); + _setExpiration(msg.sender, 0); } /** * @dev Internal implementation for setting an account's delay. See {getDelay}. * * Emits an {ERC7579ExecutorDelayUpdated} event. - * - * NOTE: The delay is set to `type(uint32).max` if the new delay is `0`. This is a - * reserved value to indicate that the module is not installed. */ function _setDelay(address account, uint32 newDelay) internal virtual { - _unsafeSetDelay( - account, - uint32(Math.ternary(newDelay == 0, NO_DELAY, newDelay)), // Safe downcast since both arguments are uint32 - minimumDelay() - ); + _unsafeSetDelay(account, newDelay, minimumDelay()); } /** @@ -259,7 +346,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Emits an {ERC7579ExecutorExpirationUpdated} event. */ function _setExpiration(address account, uint32 newExpiration) internal virtual { - _unsafeSetExpiration(account, newExpiration, minimumExpiration()); + // Safe downcast since both arguments are uint32 + _config[account].expiration = newExpiration; + emit ERC7579ExecutorExpirationUpdated(account, newExpiration); } /// @dev Version of {_setDelay} without `type(uint32).max` check and with a custom minimum setback. @@ -270,23 +359,12 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); } - /// @dev Version of {_setExpiration} without `type(uint32).max` check and with a custom minimum setback. - function _unsafeSetExpiration(address account, uint32 newExpiration, uint32 minSetback) internal virtual { - (uint32 expiration, uint32 pendingExpiration, uint48 effectTime) = getExpiration(account); - uint48 effect; - (_config[account].expiration, effect) = Time.pack(expiration, pendingExpiration, effectTime).withUpdate( - newExpiration, - minSetback - ); - emit ERC7579ExecutorExpirationUpdated(account, newExpiration, effect); - } - /** * @dev Internal version of {schedule} that takes an `account` address as an argument. * * Requirements: * - * * Operation must not have been scheduled already. See {ERC7579ExecutorOperationAlreadyScheduled}. + * * The operation must be {OperationState-Unknown}. * * Emits an {ERC7579ExecutorOperationScheduled} event. */ @@ -297,15 +375,14 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes32 salt ) internal virtual returns (bytes32 operationId, Schedule memory schedule_) { bytes32 id = hashOperation(account, mode, executionCalldata, salt); - (uint32 delay, , ) = getDelay(account); - (uint32 expiration, , ) = getExpiration(account); + _validateStateBitmap(id, _encodeStateBitmap(OperationState.Unknown)); - uint48 timepoint = Time.timestamp() + delay; - require(timepoint == 0, ERC7579ExecutorOperationAlreadyScheduled(id)); + (uint32 executableAfter, , ) = getDelay(account); + uint48 timepoint = Time.timestamp(); _schedules[id].scheduledAt = timepoint; - _schedules[id].delay = delay; - _schedules[id].expiration = expiration; + _schedules[id].executableAfter = executableAfter; + _schedules[id].expiresAfter = getExpiration(account); emit ERC7579ExecutorOperationScheduled(account, id, mode, executionCalldata, salt, timepoint); return (id, schedule_); @@ -316,12 +393,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Requirements: * - * * Operation must have been scheduled. Reverts with {ERC7579ExecutorOperationNotScheduled} otherwise. - * * Operation must not have been executed yet. Reverts with {ERC7579ExecutorOperationAlreadyExecuted} otherwise. - * * Operation must be ready for execution. Reverts with {ERC7579ExecutorOperationNotReady} otherwise. - * * Operation must not have expired. Reverts with {ERC7579ExecutorOperationExpired} otherwise. + * * The operation must be {OperationState-Ready}. * - * NOTE: Anyone can trigger execution once the the execution delay has passed. See {getSchedule}. + * NOTE: Anyone can trigger execution once the operation is {OperationState-Ready}. See {canExecute}. */ function _execute( address account, @@ -330,65 +404,61 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes32 salt ) internal virtual override returns (bytes[] memory returnData) { bytes32 id = hashOperation(account, mode, executionCalldata, salt); - (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt, bool executed) = getSchedule(id); - - require(scheduledAt != 0, ERC7579ExecutorOperationNotScheduled(id)); - require(!executed, ERC7579ExecutorOperationAlreadyExecuted(id)); - require(Time.timestamp() >= executableAt, ERC7579ExecutorOperationNotReady(id, executableAt)); - require(Time.timestamp() < expiresAt, ERC7579ExecutorOperationExpired(id, expiresAt)); + _validateStateBitmap(id, _encodeStateBitmap(OperationState.Ready)); - _config[account].delay = EXECUTED.toDelay(); // Mark the operation as executed + _schedules[id].executed = true; + emit ERC7579ExecutorOperationExecuted(account, id); return super._execute(account, mode, executionCalldata, salt); } /** * @dev Internal version of {cancel} that takes an `account` address as an argument. * - * [NOTE] - * ==== - * Expired operations can be canceled, which allows for rescheduling. Consider - * overriding this behavior in derived contracts if you want to prevent rescheduling - * of expired operations. - * - * ```solidity - * function _cancel( - * address account, - * Mode mode, - * bytes calldata executionCalldata, - * bytes32 salt - * ) internal virtual override { - * bytes32 id = hashOperation(account, mode, executionCalldata, salt); - * (, , , uint48 expiresAt, ) = getSchedule(id); - * require(expiresAt == 0, ERC7579ExecutorOperationExpired(id, expiresAt)); - * super._cancel(account, mode, executionCalldata, salt); - * } - * ``` - * ==== - * * Requirements: * - * * Operation must have been scheduled. Reverts with {ERC7579ExecutorOperationNotScheduled} otherwise. - * * Operation must not have been executed yet. Reverts with {ERC7579ExecutorOperationAlreadyExecuted} otherwise. + * * The operation must be {OperationState-Scheduled} or {OperationState-Ready}. * - * Emits an {ERC7579ExecutorOperationCanceled} event. + * Canceled operations can't be rescheduled. Emits an {ERC7579ExecutorOperationCanceled} event. */ function _cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) internal virtual { bytes32 id = hashOperation(account, mode, executionCalldata, salt); - (uint48 scheduledAt, , , bool executed) = getSchedule(id); + bytes32 allowedStates = _encodeStateBitmap(OperationState.Scheduled) | _encodeStateBitmap(OperationState.Ready); + _validateStateBitmap(id, allowedStates); - require(scheduledAt != 0, ERC7579ExecutorOperationNotScheduled(id)); - require(!executed, ERC7579ExecutorOperationAlreadyExecuted(id)); + _schedules[id].canceled = true; - _schedules[id].scheduledAt = 0; - _schedules[id].delay = 0; - _schedules[id].expiration = 0; - // _schedules[id].executed defaults to false emit ERC7579ExecutorOperationCanceled(account, id); } - /// @dev Checks whether the module is uninstalled depending on the account's `delay` value. - function _isDelayUninstalled(uint32 delay) private pure returns (bool) { - return delay == 0; + /** + * @dev Check that the current state of a proposal 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 `ProposalState` into a `bytes32` representation where each bit enabled corresponds to + * the underlying position in the `ProposalState` enum. For example: + * + * 0x000...10000 + * ^^^^^^------ ... + * ^----- Succeeded + * ^---- Defeated + * ^--- Canceled + * ^-- Active + * ^- Pending + */ + 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 index e4dac014..210506f7 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -9,8 +9,9 @@ import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol * for smart accounts. * * The module enables accounts to execute arbitrary operations, leveraging the execution - * capabilities defined in the ERC-7579 standard. Each execution emits an event for transparency - * and auditability. + * capabilities defined in the ERC-7579 standard. By default, the executor is restricted to + * operations initiated by the account itself, but this can be customized in derived contracts + * by overriding the {canExecute} function. * * 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 @@ -20,21 +21,55 @@ abstract contract ERC7579Executor is IERC7579Module { /// @dev Emitted when an operation is executed. event ERC7579ExecutorOperationExecuted(address indexed account, Mode mode, bytes callData, bytes32 salt); + /// @dev Thrown when the executor is uninstalled. + error ERC7579UnauthorizedExecution(); + /// @inheritdoc IERC7579Module function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { return moduleTypeId == MODULE_TYPE_EXECUTOR; } + /** + * @dev Check if the caller is authorized to execute operations. + * By default, this checks if the caller is the account itself. Derived contracts can + * override this to implement custom authorization logic. + * + * Example extension: + * + * ``` + * function canExecute( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt + * ) public view virtual returns (bool) { + * bool isAuthorized = ...; // custom logic to check authorization + * return isAuthorized || super.canExecute(account, mode, executionCalldata, salt); + * } + *``` + */ + function canExecute( + address account, + Mode /* mode */, + bytes calldata /* executionCalldata */, + bytes32 /* salt */ + ) public view virtual returns (bool) { + return msg.sender == account; + } + /** * @dev Executes an operation and returns the result data from the executed operation. - * See {_execute} for requirements. + * Restricted to the account itself by default. See {_execute} for requirements and + * {canExecute} for authorization checks. */ function execute( + address account, Mode mode, bytes calldata executionCalldata, bytes32 salt ) public virtual returns (bytes[] memory returnData) { - return _execute(msg.sender, mode, executionCalldata, salt); + require(canExecute(account, mode, executionCalldata, salt), ERC7579UnauthorizedExecution()); + return _execute(account, mode, executionCalldata, salt); } /** diff --git a/contracts/account/modules/ERC7579MultisigExecutor.sol b/contracts/account/modules/ERC7579Multisig.sol similarity index 60% rename from contracts/account/modules/ERC7579MultisigExecutor.sol rename to contracts/account/modules/ERC7579Multisig.sol index 641b5686..dcebeefa 100644 --- a/contracts/account/modules/ERC7579MultisigExecutor.sol +++ b/contracts/account/modules/ERC7579Multisig.sol @@ -1,31 +1,42 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import {ERC7579DelayedExecutor} from "./ERC7579DelayedExecutor.sol"; 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 {ERC7579DelayedExecutor} that uses ERC-7913 signers for multisignature - * operation scheduling. + * @dev Implementation of an {IERC7579Module} that uses ERC-7913 signers for multisignature + * validation. * - * This module extends the base time-delayed executor with multisignature capabilities, - * allowing an operation to be scheduled once it has been signed by a required threshold - * of authorized signers. The signers are represented using the ERC-7913 format, - * which concatenates a verifier address and a key: `verifier || key`. + * This module provides a base implementation for multisignature validation that can be + * attached to any function through the {_checkMultiSignature} internal function. The signers + * are represented using the ERC-7913 format, which concatenates a verifier address and + * a key: `verifier || key`. * - * Operations can be scheduled using either: - * - The account itself through the standard {schedule} function - * - Or by collecting signatures from multiple authorized signers through {scheduleMultisigner} + * Example implementation: + * + * ```solidity + * function execute( + * address account, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt, + * bytes calldata signature + * ) public virtual { + * _checkMultiSignature(account, hash, signature); + * // ... rest of execute logic + * } + * ``` * * Example use case: * - * A smart account with this module installed can schedule social recovery operations - * after obtaining approval from a set number of signers (e.g., 3-of-5 guardians), - * and then execute them after the time delay has passed. + * 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. */ -contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { +abstract contract ERC7579Multisig is IERC7579Module { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; using ERC7913Utils for bytes32; using ERC7913Utils for bytes; @@ -40,19 +51,19 @@ contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { event ERC7913ThresholdSet(address indexed account, uint256 threshold); /// @dev The `signer` already exists. - error ERC7579MultisigExecutorAlreadyExists(bytes signer); + error ERC7579MultisigAlreadyExists(bytes signer); /// @dev The `signer` does not exist. - error ERC7579MultisigExecutorNonexistentSigner(bytes signer); + error ERC7579MultisigNonexistentSigner(bytes signer); /// @dev The `signer` is less than 20 bytes long. - error ERC7579MultisigExecutorInvalidSigner(bytes signer); + error ERC7579MultisigInvalidSigner(bytes signer); /// @dev The `threshold` is unreachable given the number of `signers`. - error ERC7579MultisigExecutorUnreachableThreshold(uint256 signers, uint256 threshold); + error ERC7579MultisigUnreachableThreshold(uint256 signers, uint256 threshold); /// @dev The signatures are invalid. - error ERC7579MultisigExecutorInvalidSignatures(); + error ERC7579MultisigInvalidSignatures(); mapping(address account => EnumerableSetExtended.BytesSet) private _signersSetByAccount; mapping(address account => uint256) private _thresholdByAccount; @@ -63,17 +74,15 @@ contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { * include `signers` and `threshold`. * * The initData should be encoded as: - * `abi.encode(uint32 initialDelay, bytes[] signers, uint256 threshold)` + * `abi.encode(bytes[] signers, uint256 threshold)` * * If no signers or threshold are provided, the multisignature functionality will be * disabled until they are added later. */ - function onInstall(bytes calldata initData) public virtual override { - super.onInstall(initData); - + function onInstall(bytes calldata initData) public virtual { if (initData.length > 32) { // More than just delay parameter - (, bytes[] memory signers_, uint256 threshold_) = abi.decode(initData, (uint32, bytes[], uint256)); + (bytes[] memory signers_, uint256 threshold_) = abi.decode(initData, (bytes[], uint256)); _addSigners(msg.sender, signers_); _setThreshold(msg.sender, threshold_); } @@ -88,15 +97,9 @@ contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { * 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 override { + function onUninstall(bytes calldata /* data */) public virtual { _signersSetByAccount[msg.sender].clear(); delete _thresholdByAccount[msg.sender]; - super.onUninstall(data); - } - - /// @dev Returns the unique identifier of the `signer`. - function signerId(bytes memory signer) public pure virtual returns (bytes32) { - return keccak256(signer); } /** @@ -130,6 +133,11 @@ contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { /** * @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); @@ -138,6 +146,11 @@ contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { /** * @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); @@ -146,93 +159,108 @@ contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { /** * @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 Schedules an operation using signatures from multiple authorized {signers}. - * The operation will be scheduled if the number of valid signatures meets or exceeds - * the threshold set for the target account. + * @dev Checks whether the number of valid signatures meets or exceeds the + * threshold set for the target account. Reverts with {ERC7579MultisigInvalidSignatures} + * if the multisignature is not valid. * * 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. See {hashOperation} for the operation hash. - * - * NOTE: Signers should be ordered by their {signerId} to prevent duplications. + * Where `signingSigners` are the authorized signers and signatures are their corresponding + * signatures of the operation `hash`. */ - function scheduleMultisigner( - address account, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata signature - ) public virtual returns (bytes32 operationId) { - bytes32 hash = hashOperation(account, mode, executionCalldata, salt); + function _checkMultiSignature(address account, bytes32 hash, bytes calldata signature) internal view virtual { (bytes[] memory signingSigners, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); require( - _validateNSignatures(account, hash, signingSigners, signatures) && - _validateThreshold(account, signingSigners), - ERC7579MultisigExecutorInvalidSignatures() + _validateThreshold(account, signingSigners) && + _validateNSignatures(account, hash, signingSigners, signatures), + ERC7579MultisigInvalidSignatures() ); - - // Schedule the operation - (operationId, ) = _schedule(account, mode, executionCalldata, salt); - return operationId; } - /// @dev Adds the `newSigners` to those allowed to sign on behalf of the account. + /** + * @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 { EnumerableSetExtended.BytesSet storage signerSet = _signers(account); for (uint256 i = 0; i < newSigners.length; i++) { bytes memory signer = newSigners[i]; - require(signer.length >= 20, ERC7579MultisigExecutorInvalidSigner(signer)); - require(signerSet.add(signer), ERC7579MultisigExecutorAlreadyExists(signer)); + require(signer.length >= 20, ERC7579MultisigInvalidSigner(signer)); + require(signerSet.add(signer), ERC7579MultisigAlreadyExists(signer)); } emit ERC7913SignersAdded(account, newSigners); } - /// @dev Removes the `oldSigners` from the authorized signers for the account. + /** + * @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 { EnumerableSetExtended.BytesSet storage signerSet = _signers(account); for (uint256 i = 0; i < oldSigners.length; i++) { bytes memory signer = oldSigners[i]; - require(signerSet.remove(signer), ERC7579MultisigExecutorNonexistentSigner(signer)); + require(signerSet.remove(signer), ERC7579MultisigNonexistentSigner(signer)); } _validateReachableThreshold(account); emit ERC7913SignersRemoved(account, oldSigners); } - /// @dev Sets the signatures `threshold` required to approve a multisignature operation. + /** + * @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}. + /** + * @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, - ERC7579MultisigExecutorUnreachableThreshold(totalSigners, currentThreshold) - ); + 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 `signerId` to ensure no duplicates and to optimize - * the verification process. The function will return `false` if the signers are not properly ordered. + * 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: * @@ -250,7 +278,7 @@ contract ERC7579MultisigExecutor is ERC7579DelayedExecutor { return false; } } - return hash.areValidNSignaturesNow(signingSigners, signatures, signerId); + return hash.areValidSignaturesNow(signingSigners, signatures); } /** diff --git a/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol b/contracts/account/modules/ERC7579MultisigWeighted.sol similarity index 74% rename from contracts/account/modules/ERC7579MultisigWeightedExecutor.sol rename to contracts/account/modules/ERC7579MultisigWeighted.sol index f6a62e91..9abcca63 100644 --- a/contracts/account/modules/ERC7579MultisigWeightedExecutor.sol +++ b/contracts/account/modules/ERC7579MultisigWeighted.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import {ERC7579MultisigExecutor} from "./ERC7579MultisigExecutor.sol"; +import {ERC7579Multisig} from "./ERC7579Multisig.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; /** - * @dev Extension of {ERC7579MultisigExecutor} that supports weighted signatures. + * @dev Extension of {ERC7579Multisig} that supports weighted signatures. * - * This module extends the multisignature executor to allow assigning different weights + * 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. * @@ -23,23 +23,23 @@ import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.s * 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). */ -contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { +abstract contract ERC7579MultisigWeighted is ERC7579Multisig { using EnumerableSetExtended for EnumerableSetExtended.BytesSet; - // Mapping from account => signerId => weight - mapping(address account => mapping(bytes32 signerId => uint256 weight)) private _weightsByAccount; + // 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 _totalWeightByAccount; + mapping(address account => uint256 totalWeight) private _totalWeight; /// @dev Emitted when a signer's weight is changed. event ERC7913SignerWeightChanged(address indexed account, bytes indexed signer, uint256 weight); /// @dev Thrown when a signer's weight is invalid. - error ERC7579MultisigExecutorInvalidWeight(bytes signer, uint256 weight); + error ERC7579MultisigInvalidWeight(bytes signer, uint256 weight); /// @dev Thrown when the arrays lengths don't match. - error ERC7579MultisigExecutorMismatchedLength(); + error ERC7579MultisigMismatchedLength(); /** * @dev Sets up the module's initial configuration when installed by an account. @@ -47,37 +47,32 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { * signer weights. * * The initData should be encoded as: - * `abi.encode(uint32 initialDelay, bytes[] signers, uint256 threshold, uint256[] weights)` + * `abi.encode(bytes[] signers, uint256 threshold, uint256[] weights)` * * If weights are not provided but signers are, all signers default to weight 1. */ function onInstall(bytes calldata initData) public virtual override { super.onInstall(initData); - - (, bytes[] memory signers, uint256 threshold, uint256[] memory weights) = abi.decode( - initData, - (uint32, bytes[], uint256, uint256[]) - ); - - _addSigners(msg.sender, signers); - _setSignerWeights(msg.sender, signers, weights); - _setThreshold(msg.sender, threshold); + if (initData.length > 96) { + (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 {ERC7579MultisigExecutor-onUninstall}. + * See {ERC7579Multisig-onUninstall}. */ function onUninstall(bytes calldata data) public virtual override { address account = msg.sender; bytes[] memory allSigners = signers(account); for (uint256 i = 0; i < allSigners.length; i++) { - delete _weightsByAccount[account][signerId(allSigners[i])]; + delete _weights[account][allSigners[i]]; } - delete _totalWeightByAccount[account]; + delete _totalWeight[account]; // Call parent implementation which will clear signers and threshold super.onUninstall(data); @@ -90,7 +85,7 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { /// @dev Gets the total weight of all signers for a specific account. function totalWeight(address account) public view virtual returns (uint256) { - return Math.max(_totalWeightByAccount[account], _signers(account).length()); + return _totalWeight[account]; // Doesn't need Math.max because it's incremented by the default 1 in `_addSigners` } /** @@ -106,7 +101,7 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { * 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(_weightsByAccount[account][signerId(signer)], 1); + return Math.max(_weights[account][signer], 1); } /** @@ -114,44 +109,44 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { * * Requirements: * - * - `signers` and `weights` arrays must have the same length. Reverts with {ERC7579MultisigExecutorMismatchedLength} on mismatch. - * - Each signer must exist in the set of authorized signers. Reverts with {ERC7579MultisigExecutorNonexistentSigner} if not. - * - Each weight must be greater than 0. Reverts with {ERC7579MultisigExecutorInvalidWeight} if not. - * - See {_validateReachableThreshold} for the threshold validation. + * * `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 {ERC7913SignerWeightChanged} for each signer. */ function _setSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) internal virtual { - require(signers.length == newWeights.length, ERC7579MultisigExecutorMismatchedLength()); + require(signers.length == newWeights.length, ERC7579MultisigMismatchedLength()); uint256 oldWeight = _weightSigners(account, signers); for (uint256 i = 0; i < signers.length; i++) { bytes memory signer = signers[i]; uint256 newWeight = newWeights[i]; - require(isSigner(account, signer), ERC7579MultisigExecutorNonexistentSigner(signer)); - require(newWeight > 0, ERC7579MultisigExecutorInvalidWeight(signer, newWeight)); + require(isSigner(account, signer), ERC7579MultisigNonexistentSigner(signer)); + require(newWeight > 0, ERC7579MultisigInvalidWeight(signer, newWeight)); } _unsafeSetSignerWeights(account, signers, newWeights); - _totalWeightByAccount[account] = _totalWeightByAccount[account] - oldWeight + _weightSigners(account, signers); + _totalWeight[account] = totalWeight(account) - oldWeight + _weightSigners(account, signers); _validateReachableThreshold(account); } /** - * @dev Override to add weight tracking. See {ERC7579MultisigExecutor-_addSigners}. + * @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); - _totalWeightByAccount[account] += newSigners.length; // Default weight of 1 per signer. + _totalWeight[account] += newSigners.length; // Default weight of 1 per signer. } - /// @dev Override to handle weight tracking during removal. See {ERC7579MultisigExecutor-_removeSigners}. + /// @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 - _totalWeightByAccount[account] -= removedWeight; + _totalWeight[account] -= removedWeight; } _unsafeSetSignerWeights(account, oldSigners, new uint256[](oldSigners.length)); super._removeSigners(account, oldSigners); @@ -168,7 +163,7 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { function _validateReachableThreshold(address account) internal view virtual override { uint256 weight = totalWeight(account); uint256 currentThreshold = threshold(account); - require(weight >= currentThreshold, ERC7579MultisigExecutorUnreachableThreshold(weight, currentThreshold)); + require(weight >= currentThreshold, ERC7579MultisigUnreachableThreshold(weight, currentThreshold)); } /** @@ -208,7 +203,7 @@ contract ERC7579MultisigWeightedExecutor is ERC7579MultisigExecutor { */ function _unsafeSetSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) private { for (uint256 i = 0; i < signers.length; i++) { - delete _weightsByAccount[account][signerId(signers[i])]; + delete _weights[account][signers[i]]; emit ERC7913SignerWeightChanged(account, signers[i], newWeights[i]); } } From a62054485f4b9695a2c40c355d9916329bbf788d Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 8 May 2025 09:22:14 -0600 Subject: [PATCH 48/90] Nit --- contracts/account/modules/ERC7579DelayedExecutor.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 99266854..1de58b26 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -201,7 +201,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { address account ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].delay.getFull(); - bool installed = IERC7579ModuleConfig(msg.sender).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + bool installed = IERC7579ModuleConfig(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); return ( // Safe downcast since both arguments are uint32 uint32(Math.ternary(installed, 0, Math.max(currentDelay, minimumDelay()))), @@ -212,7 +212,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Expiration delay for account operations. If not set, returns the minimum delay. function getExpiration(address account) public view virtual returns (uint32 expiration) { - bool installed = IERC7579ModuleConfig(msg.sender).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + bool installed = IERC7579ModuleConfig(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); // Safe downcast since both arguments are uint32 return uint32(Math.ternary(!installed, 0, Math.max(_config[account].expiration, minimumExpiration()))); } From fc3e500e71c93fc064f33cb8c204bdd6f733e4c8 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 8 May 2025 09:23:26 -0600 Subject: [PATCH 49/90] Nit --- contracts/account/modules/ERC7579DelayedExecutor.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 1de58b26..78b7a4bb 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -204,7 +204,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bool installed = IERC7579ModuleConfig(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); return ( // Safe downcast since both arguments are uint32 - uint32(Math.ternary(installed, 0, Math.max(currentDelay, minimumDelay()))), + uint32(Math.ternary(!installed, 0, Math.max(currentDelay, minimumDelay()))), newDelay, effect ); From 636da5598a3ca845564cd59e027abc8e89380029 Mon Sep 17 00:00:00 2001 From: Gonzalo Othacehe Date: Thu, 8 May 2025 13:22:47 -0300 Subject: [PATCH 50/90] add util function `isModuleInstalled` to avoid repeating --- contracts/account/modules/ERC7579DelayedExecutor.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 78b7a4bb..7e571ff1 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -201,7 +201,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { address account ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].delay.getFull(); - bool installed = IERC7579ModuleConfig(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + bool installed = isModuleInstalled(account); return ( // Safe downcast since both arguments are uint32 uint32(Math.ternary(!installed, 0, Math.max(currentDelay, minimumDelay()))), @@ -212,7 +212,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Expiration delay for account operations. If not set, returns the minimum delay. function getExpiration(address account) public view virtual returns (uint32 expiration) { - bool installed = IERC7579ModuleConfig(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + bool installed = isModuleInstalled(account); // Safe downcast since both arguments are uint32 return uint32(Math.ternary(!installed, 0, Math.max(_config[account].expiration, minimumExpiration()))); } @@ -266,7 +266,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * `initData` must be empty or decode correctly to `(uint32, uint32)`. */ function onInstall(bytes calldata initData) public virtual { - bool installed = IERC7579ModuleConfig(msg.sender).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + bool installed = isModuleInstalled(msg.sender); if (!installed) { (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) @@ -461,4 +461,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { function _encodeStateBitmap(OperationState operationState) internal pure returns (bytes32) { return bytes32(1 << uint8(operationState)); } + + function isModuleInstalled(address account) public view returns (bool) { + return IERC7579ModuleConfig(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); + } } From d3f8701fc7c2a36a907b5f6468a978c4e0784ce1 Mon Sep 17 00:00:00 2001 From: Gonzalo Othacehe Date: Thu, 8 May 2025 13:27:23 -0300 Subject: [PATCH 51/90] inline `isModuleInstalled` call --- contracts/account/modules/ERC7579DelayedExecutor.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 7e571ff1..9edf6c9d 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -201,10 +201,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { address account ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].delay.getFull(); - bool installed = isModuleInstalled(account); return ( // Safe downcast since both arguments are uint32 - uint32(Math.ternary(!installed, 0, Math.max(currentDelay, minimumDelay()))), + uint32(Math.ternary(!isModuleInstalled(account), 0, Math.max(currentDelay, minimumDelay()))), newDelay, effect ); @@ -212,9 +211,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Expiration delay for account operations. If not set, returns the minimum delay. function getExpiration(address account) public view virtual returns (uint32 expiration) { - bool installed = isModuleInstalled(account); // Safe downcast since both arguments are uint32 - return uint32(Math.ternary(!installed, 0, Math.max(_config[account].expiration, minimumExpiration()))); + return + uint32( + Math.ternary(!isModuleInstalled(account), 0, Math.max(_config[account].expiration, minimumExpiration())) + ); } /// @dev Schedule for an operation. Returns default values if not set (i.e. `uint48(0)`, `uint48(0)`, `uint48(0)`). @@ -266,8 +267,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * `initData` must be empty or decode correctly to `(uint32, uint32)`. */ function onInstall(bytes calldata initData) public virtual { - bool installed = isModuleInstalled(msg.sender); - if (!installed) { + if (!isModuleInstalled(msg.sender)) { (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) : (0, 0); From 3a6c8bd09327e93efdcf28564feaa9a50e98c058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Fri, 9 May 2025 16:52:33 -0600 Subject: [PATCH 52/90] Apply suggestions from code review Co-authored-by: Gonzalo Othacehe <86085168+gonzaotc@users.noreply.github.com> --- contracts/account/modules/ERC7579Multisig.sol | 18 ++++++++---------- .../modules/ERC7579MultisigWeighted.sol | 11 +++++++---- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/contracts/account/modules/ERC7579Multisig.sol b/contracts/account/modules/ERC7579Multisig.sol index dcebeefa..8da6e247 100644 --- a/contracts/account/modules/ERC7579Multisig.sol +++ b/contracts/account/modules/ERC7579Multisig.sol @@ -109,7 +109,7 @@ abstract contract ERC7579Multisig is IERC7579Module { * can be expensive or may result in unbounded computation. */ function signers(address account) public view virtual returns (bytes[] memory) { - return _signersSetByAccount[account].values(); + return _signers().values(); } /// @dev Returns whether the `signer` is an authorized signer for the specified account. @@ -197,12 +197,11 @@ abstract contract ERC7579Multisig is IERC7579Module { * * Each of `newSigners` must not be authorized. Reverts with {ERC7579MultisigAlreadyExists} if it already exists. */ function _addSigners(address account, bytes[] memory newSigners) internal virtual { - EnumerableSetExtended.BytesSet storage signerSet = _signers(account); - - for (uint256 i = 0; i < newSigners.length; i++) { + uint256 newSignersLength = newSigners.length; + for (uint256 i = 0; i < newSignersLength; i++) { bytes memory signer = newSigners[i]; require(signer.length >= 20, ERC7579MultisigInvalidSigner(signer)); - require(signerSet.add(signer), ERC7579MultisigAlreadyExists(signer)); + require(_signers(account).add(signer), ERC7579MultisigAlreadyExists(signer)); } emit ERC7913SignersAdded(account, newSigners); @@ -217,11 +216,10 @@ abstract contract ERC7579Multisig is IERC7579Module { * * The threshold must remain reachable after removal. See {_validateReachableThreshold} for details. */ function _removeSigners(address account, bytes[] memory oldSigners) internal virtual { - EnumerableSetExtended.BytesSet storage signerSet = _signers(account); - - for (uint256 i = 0; i < oldSigners.length; i++) { + uint256 oldSignersLength = oldSigners.length; + for (uint256 i = 0; i < oldSignersLength; i++) { bytes memory signer = oldSigners[i]; - require(signerSet.remove(signer), ERC7579MultisigNonexistentSigner(signer)); + require(_signers(account).remove(signer), ERC7579MultisigNonexistentSigner(signer)); } _validateReachableThreshold(account); @@ -266,7 +264,7 @@ abstract contract ERC7579Multisig is IERC7579Module { * * * The `signatures` array must be at least the `signers` array's length. */ - function _validateNSignatures( + function _validateSignatures( address account, bytes32 hash, bytes[] memory signingSigners, diff --git a/contracts/account/modules/ERC7579MultisigWeighted.sol b/contracts/account/modules/ERC7579MultisigWeighted.sol index 9abcca63..7930665d 100644 --- a/contracts/account/modules/ERC7579MultisigWeighted.sol +++ b/contracts/account/modules/ERC7579MultisigWeighted.sol @@ -33,7 +33,7 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { mapping(address account => uint256 totalWeight) private _totalWeight; /// @dev Emitted when a signer's weight is changed. - event ERC7913SignerWeightChanged(address indexed account, bytes indexed signer, uint256 weight); + 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); @@ -69,7 +69,8 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { address account = msg.sender; bytes[] memory allSigners = signers(account); - for (uint256 i = 0; i < allSigners.length; i++) { + uint256 allSignersLength = allSigners.length; + for (uint256 i = 0; i < allSignersLength; i++) { delete _weights[account][allSigners[i]]; } delete _totalWeight[account]; @@ -117,7 +118,8 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { * Emits {ERC7913SignerWeightChanged} for each signer. */ function _setSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) internal virtual { - require(signers.length == newWeights.length, ERC7579MultisigMismatchedLength()); + uint256 signersLength = signers.length; + require(signersLength == newWeights.length, ERC7579MultisigMismatchedLength()); uint256 oldWeight = _weightSigners(account, signers); for (uint256 i = 0; i < signers.length; i++) { @@ -202,7 +204,8 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { * Emits {ERC7913SignerWeightChanged} for each signer. */ function _unsafeSetSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) private { - for (uint256 i = 0; i < signers.length; i++) { + uint256 signersLength = signers.lenght; + for (uint256 i = 0; i < signersLength; i++) { delete _weights[account][signers[i]]; emit ERC7913SignerWeightChanged(account, signers[i], newWeights[i]); } From f8eb662bd0691689a5139ef78bbb0c215222203a Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 9 May 2025 16:54:09 -0600 Subject: [PATCH 53/90] Apply review recommendations --- .../modules/ERC7579DelayedExecutor.sol | 24 +++++++++---------- contracts/account/modules/ERC7579Multisig.sol | 15 ++++++------ .../modules/ERC7579MultisigWeighted.sol | 15 ++++++------ .../modules/ERC7579SignatureValidator.sol | 12 ++++------ 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 9edf6c9d..119a67a2 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -41,9 +41,10 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { } struct ExecutionConfig { - // 1 slot = 112 + 32 + 1 = 145 bits ~ 18 bytes + // 1 slot = 112 + 32 + 1 + 1 = 146 bits ~ 18 bytes Time.Delay delay; uint32 expiration; + bool installed; } enum OperationState { @@ -79,7 +80,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /** * @dev The current state of a operation is not the expected. The `expectedStates` is a bitmap with the - * bits enabled for each ProposalState enum position counting from right to left. See {_encodeStateBitmap}. + * 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). */ @@ -203,7 +204,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].delay.getFull(); return ( // Safe downcast since both arguments are uint32 - uint32(Math.ternary(!isModuleInstalled(account), 0, Math.max(currentDelay, minimumDelay()))), + uint32(Math.ternary(!_config[account].installed, 0, Math.max(currentDelay, minimumDelay()))), newDelay, effect ); @@ -214,7 +215,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { // Safe downcast since both arguments are uint32 return uint32( - Math.ternary(!isModuleInstalled(account), 0, Math.max(_config[account].expiration, minimumExpiration())) + Math.ternary(!_config[account].installed, 0, Math.max(_config[account].expiration, minimumExpiration())) ); } @@ -263,11 +264,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Requirements: * * * The account (i.e `msg.sender`) must implement the {IERC7579ModuleConfig} interface. - * * The {IERC7579ModuleConfig-isModuleInstalled} function must return not revert. * * `initData` must be empty or decode correctly to `(uint32, uint32)`. */ function onInstall(bytes calldata initData) public virtual { - if (!isModuleInstalled(msg.sender)) { + if (!_config[msg.sender].installed) { + _config[msg.sender].installed = true; (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) : (0, 0); @@ -327,6 +328,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * See {AccountERC7579-uninstallModule}. */ function onUninstall(bytes calldata) public virtual { + _config[msg.sender].installed = false; _unsafeSetDelay(msg.sender, 0, 0); _setExpiration(msg.sender, 0); } @@ -432,7 +434,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { } /** - * @dev Check that the current state of a proposal matches the requirements described by the `allowedStates` bitmap. + * @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. @@ -447,8 +449,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { } /** - * @dev Encodes a `ProposalState` into a `bytes32` representation where each bit enabled corresponds to - * the underlying position in the `ProposalState` enum. For example: + * @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 * ^^^^^^------ ... @@ -461,8 +463,4 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { function _encodeStateBitmap(OperationState operationState) internal pure returns (bytes32) { return bytes32(1 << uint8(operationState)); } - - function isModuleInstalled(address account) public view returns (bool) { - return IERC7579ModuleConfig(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), ""); - } } diff --git a/contracts/account/modules/ERC7579Multisig.sol b/contracts/account/modules/ERC7579Multisig.sol index 8da6e247..2e264058 100644 --- a/contracts/account/modules/ERC7579Multisig.sol +++ b/contracts/account/modules/ERC7579Multisig.sol @@ -78,9 +78,12 @@ abstract contract ERC7579Multisig is IERC7579Module { * * 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) { + 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_); @@ -109,12 +112,12 @@ abstract contract ERC7579Multisig is IERC7579Module { * can be expensive or may result in unbounded computation. */ function signers(address account) public view virtual returns (bytes[] memory) { - return _signers().values(); + 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 _signersSetByAccount[account].contains(signer); + return _signers(account).contains(signer); } /// @dev Returns the set of authorized signers for the specified account. @@ -183,7 +186,7 @@ abstract contract ERC7579Multisig is IERC7579Module { (bytes[] memory signingSigners, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); require( _validateThreshold(account, signingSigners) && - _validateNSignatures(account, hash, signingSigners, signatures), + _validateSignatures(account, hash, signingSigners, signatures), ERC7579MultisigInvalidSignatures() ); } @@ -203,7 +206,6 @@ abstract contract ERC7579Multisig is IERC7579Module { require(signer.length >= 20, ERC7579MultisigInvalidSigner(signer)); require(_signers(account).add(signer), ERC7579MultisigAlreadyExists(signer)); } - emit ERC7913SignersAdded(account, newSigners); } @@ -221,7 +223,6 @@ abstract contract ERC7579Multisig is IERC7579Module { bytes memory signer = oldSigners[i]; require(_signers(account).remove(signer), ERC7579MultisigNonexistentSigner(signer)); } - _validateReachableThreshold(account); emit ERC7913SignersRemoved(account, oldSigners); } @@ -281,7 +282,7 @@ abstract contract ERC7579Multisig is IERC7579Module { /** * @dev Validates that the number of signers meets the {threshold} requirement. - * Assumes the signers were already validated. See {_validateNSignatures} for more details. + * Assumes the signers were already validated. See {_validateSignatures} for more details. */ function _validateThreshold( address account, diff --git a/contracts/account/modules/ERC7579MultisigWeighted.sol b/contracts/account/modules/ERC7579MultisigWeighted.sol index 7930665d..2806af88 100644 --- a/contracts/account/modules/ERC7579MultisigWeighted.sol +++ b/contracts/account/modules/ERC7579MultisigWeighted.sol @@ -52,8 +52,9 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { * If weights are not provided but signers are, all signers default to weight 1. */ function onInstall(bytes calldata initData) public virtual override { + bool installed = _signers(msg.sender).length() > 0; super.onInstall(initData); - if (initData.length > 96) { + if (initData.length > 96 && !installed) { (bytes[] memory signers, , uint256[] memory weights) = abi.decode(initData, (bytes[], uint256, uint256[])); _setSignerWeights(msg.sender, signers, weights); } @@ -115,14 +116,14 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { * * Each weight must be greater than 0. Reverts with {ERC7579MultisigInvalidWeight} if not. * * See {_validateReachableThreshold} for the threshold validation. * - * Emits {ERC7913SignerWeightChanged} for each signer. + * 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 < signers.length; i++) { + for (uint256 i = 0; i < signersLength; i++) { bytes memory signer = signers[i]; uint256 newWeight = newWeights[i]; require(isSigner(account, signer), ERC7579MultisigNonexistentSigner(signer)); @@ -201,13 +202,13 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { * * * The `newWeights` array must be at least as large as the `signers` array. Panics otherwise. * - * Emits {ERC7913SignerWeightChanged} for each signer. + * Emits {ERC7579MultisigWeightChanged} for each signer. */ function _unsafeSetSignerWeights(address account, bytes[] memory signers, uint256[] memory newWeights) private { - uint256 signersLength = signers.lenght; + uint256 signersLength = signers.length; for (uint256 i = 0; i < signersLength; i++) { - delete _weights[account][signers[i]]; - emit ERC7913SignerWeightChanged(account, signers[i], newWeights[i]); + _weights[account][signers[i]] = newWeights[i]; + emit ERC7579MultisigWeightChanged(account, signers[i], newWeights[i]); } } } diff --git a/contracts/account/modules/ERC7579SignatureValidator.sol b/contracts/account/modules/ERC7579SignatureValidator.sol index 727ae0f7..cc3e4599 100644 --- a/contracts/account/modules/ERC7579SignatureValidator.sol +++ b/contracts/account/modules/ERC7579SignatureValidator.sol @@ -58,9 +58,6 @@ contract ERC7579SignatureValidator is ERC7579Validator { /// @dev Thrown when the signer length is less than 20 bytes. error ERC7579SignatureValidatorInvalidSignerLength(); - /// @dev Thrown when the module is already installed. - error ERC7579SignatureValidatorAlreadyInstalled(); - /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). function signer(address account) public view virtual returns (bytes memory) { return _signers[account]; @@ -70,12 +67,13 @@ contract ERC7579SignatureValidator is ERC7579Validator { * @dev See {IERC7579Module-onInstall}. * Reverts with {ERC7579SignatureValidatorAlreadyInstalled} if the module is already installed. * - * IMPORTANT: 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 revert. + * NOTE: An account can only call onInstall once. If called directly by the account, + * the signer will be set to the provided data. Future installations will behave as a no-op. */ function onInstall(bytes calldata data) public virtual { - require(signer(msg.sender).length == 0, ERC7579SignatureValidatorAlreadyInstalled()); - setSigner(data); + if (signer(msg.sender).length == 0) { + setSigner(data); + } } /** From 6ae14402779743100624320dab739cd133bd6cfa Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 9 May 2025 17:03:04 -0600 Subject: [PATCH 54/90] Fix test --- test/account/modules/ERC7579SignatureValidator.test.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/account/modules/ERC7579SignatureValidator.test.js b/test/account/modules/ERC7579SignatureValidator.test.js index 88bafef3..6fd328ef 100644 --- a/test/account/modules/ERC7579SignatureValidator.test.js +++ b/test/account/modules/ERC7579SignatureValidator.test.js @@ -71,11 +71,9 @@ describe('ERC7579SignatureValidator', function () { const signerData = ethers.solidityPacked(['address'], [signerECDSA.address]); await expect(this.mockFromAccount.onInstall(signerData)).to.not.be.reverted; - // Second installation should fail - await expect(this.mockFromAccount.onInstall(signerData)).to.be.revertedWithCustomError( - this.mock, - 'ERC7579SignatureValidatorAlreadyInstalled', - ); + // Second installation should behave as a no-op + await this.mockFromAccount.onInstall(ethers.solidityPacked(['address'], [ethers.Wallet.createRandom().address])); // Not revert + await expect(this.mock.signer(this.mockAccount.address)).to.eventually.equal(signerData); // No change in signers }); it('emits event on ERC7579SignatureValidatorSignerSet on both installation and uninstallation', async function () { From 8a3903ce908e7b2029cb82505f8625f786c48092 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 9 May 2025 17:03:38 -0600 Subject: [PATCH 55/90] lint --- test/utils/cryptography/ERC7739Utils.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/cryptography/ERC7739Utils.test.js b/test/utils/cryptography/ERC7739Utils.test.js index bcd24c64..c568919a 100644 --- a/test/utils/cryptography/ERC7739Utils.test.js +++ b/test/utils/cryptography/ERC7739Utils.test.js @@ -203,7 +203,7 @@ describe('ERC7739Utils', function () { it(descr, async function () { await expect(this.mock.$decodeContentsDescr(contentsDescr)).to.eventually.deep.equal([ contentTypeName ?? '', - contentTypeName ? contentType ?? contentsDescr : '', + contentTypeName ? (contentType ?? contentsDescr) : '', ]); }); } From 5f0b16d4a6a79a86e2d19b887bba4a369006c0b6 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 9 May 2025 17:07:47 -0600 Subject: [PATCH 56/90] Add missing note --- contracts/account/modules/ERC7579MultisigWeighted.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contracts/account/modules/ERC7579MultisigWeighted.sol b/contracts/account/modules/ERC7579MultisigWeighted.sol index 2806af88..fc8ac15d 100644 --- a/contracts/account/modules/ERC7579MultisigWeighted.sol +++ b/contracts/account/modules/ERC7579MultisigWeighted.sol @@ -50,6 +50,9 @@ abstract contract ERC7579MultisigWeighted is ERC7579Multisig { * `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; From 086b3c23f201087bd997addf36f6d94beeb4a140 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 9 May 2025 17:08:52 -0600 Subject: [PATCH 57/90] up --- contracts/account/modules/ERC7579DelayedExecutor.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 119a67a2..94f51f1c 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -454,11 +454,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * 0x000...10000 * ^^^^^^------ ... - * ^----- Succeeded - * ^---- Defeated - * ^--- Canceled - * ^-- Active - * ^- Pending + * ^----- Canceled + * ^---- Executed + * ^--- Ready + * ^-- Scheduled + * ^- Unknown */ function _encodeStateBitmap(OperationState operationState) internal pure returns (bytes32) { return bytes32(1 << uint8(operationState)); From 4a8cab59538054b12b5d708c3cdbbbfabeca3e45 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 9 May 2025 17:27:35 -0600 Subject: [PATCH 58/90] Add ERC7579MultisigConfirmation --- .../modules/ERC7579MultisigConfirmation.sol | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 contracts/account/modules/ERC7579MultisigConfirmation.sol diff --git a/contracts/account/modules/ERC7579MultisigConfirmation.sol b/contracts/account/modules/ERC7579MultisigConfirmation.sol new file mode 100644 index 00000000..dfe91d3c --- /dev/null +++ b/contracts/account/modules/ERC7579MultisigConfirmation.sol @@ -0,0 +1,56 @@ +// 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)"); + + /// @dev Error thrown when a `signer`'s confirmation signature is invalid + error ERC7579MultisigInvalidConfirmationSignature(bytes signer); + + /// @dev Generates a hash that signers must sign to confirm their addition to the multisig of `account`. + function _signableConfirmationHash(address account) internal view virtual returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(MULTISIG_CONFIRMATION, account, address(this)))); + } + + /** + * @dev Extends {ERC7579Multisig-_addSigners} _addSigners to require confirmation signatures + * Each entry in newSigners must be ABI-encoded as: + * + * ```solidity + * abi.encode(bytes signer, bytes signature) + * ``` + * + * * 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++) { + (bytes memory signer, bytes memory signature) = abi.decode(newSigners[i], (bytes, bytes)); + require( + ERC7913Utils.isValidSignatureNow(signer, _signableConfirmationHash(account), signature), + ERC7579MultisigInvalidConfirmationSignature(signer) + ); + newSigners[i] = signer; + } + super._addSigners(account, newSigners); + } +} From 7c6a28a1195cdeb3e992cd23eab23b558c5d38f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Fri, 9 May 2025 17:29:05 -0600 Subject: [PATCH 59/90] Update contracts/account/modules/ERC7579MultisigConfirmation.sol --- contracts/account/modules/ERC7579MultisigConfirmation.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/account/modules/ERC7579MultisigConfirmation.sol b/contracts/account/modules/ERC7579MultisigConfirmation.sol index dfe91d3c..18fc2c54 100644 --- a/contracts/account/modules/ERC7579MultisigConfirmation.sol +++ b/contracts/account/modules/ERC7579MultisigConfirmation.sol @@ -39,7 +39,7 @@ abstract contract ERC7579MultisigConfirmation is ERC7579Multisig, EIP712 { * * 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. + * the function reverts with {ERC7579MultisigInvalidConfirmationSignature}. */ function _addSigners(address account, bytes[] memory newSigners) internal virtual override { uint256 newSignersLength = newSigners.length; From baa1974b88daf8cc857d443ef225a6bdd722ab7c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sat, 10 May 2025 10:51:51 -0600 Subject: [PATCH 60/90] Add ERC7579Executor tests --- .../account/modules/ERC7579ExecutorMock.sol | 12 +++++ test/account/modules/ERC7579Executor.test.js | 45 +++++++++++++++++++ .../account/modules/ERC7579Module.behavior.js | 42 +++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 contracts/mocks/account/modules/ERC7579ExecutorMock.sol create mode 100644 test/account/modules/ERC7579Executor.test.js diff --git a/contracts/mocks/account/modules/ERC7579ExecutorMock.sol b/contracts/mocks/account/modules/ERC7579ExecutorMock.sol new file mode 100644 index 00000000..f62e5646 --- /dev/null +++ b/contracts/mocks/account/modules/ERC7579ExecutorMock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7579Executor} from "../../../account/modules/ERC7579Executor.sol"; +import {MODULE_TYPE_EXECUTOR, IERC7579Hook} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; + +abstract contract ERC7579ExecutorMock is ERC7579Executor { + function onInstall(bytes calldata data) public view {} + + function onUninstall(bytes calldata data) public view {} +} diff --git a/test/account/modules/ERC7579Executor.test.js b/test/account/modules/ERC7579Executor.test.js new file mode 100644 index 00000000..9c349dbb --- /dev/null +++ b/test/account/modules/ERC7579Executor.test.js @@ -0,0 +1,45 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { ERC4337Helper } = require('../../helpers/erc4337'); + +const { MODULE_TYPE_EXECUTOR } = require('@openzeppelin/contracts/test/helpers/erc7579'); +const { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Executor } = require('./ERC7579Module.behavior'); + +async function fixture() { + const [other] = await ethers.getSigners(); + + // Deploy ERC-7579 validator module + const mock = await ethers.deployContract('$ERC7579ExecutorMock'); + 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)); + + return { + moduleType: MODULE_TYPE_EXECUTOR, + mock, + mockAccount, + mockFromAccount, + other, + target, + installData, + }; +} + +describe('ERC7579Validator', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeERC7579Module(); + shouldBehaveLikeERC7579Executor(); +}); diff --git a/test/account/modules/ERC7579Module.behavior.js b/test/account/modules/ERC7579Module.behavior.js index 7645c4ba..447e2e23 100644 --- a/test/account/modules/ERC7579Module.behavior.js +++ b/test/account/modules/ERC7579Module.behavior.js @@ -1,6 +1,14 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('@openzeppelin/contracts/test/helpers/erc4337'); +const { + encodeMode, + encodeSingle, + CALL_TYPE_CALL, + EXEC_TYPE_DEFAULT, +} = require('@openzeppelin/contracts/test/helpers/erc7579'); +const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); +const { entrypoint } = require('hardhat'); function shouldBehaveLikeERC7579Module() { describe('behaves like ERC7579Module', function () { @@ -64,7 +72,41 @@ function shouldBehaveLikeERC7579Validator() { }); } +function shouldBehaveLikeERC7579Executor() { + describe('behaves like ERC7579Executor', function () { + const mode = encodeMode({ callType: CALL_TYPE_CALL, execType: EXEC_TYPE_DEFAULT }); + + beforeEach(async function () { + await this.mockAccount.deploy(); + await impersonate(entrypoint.v08.target).then(asEntrypoint => + this.mockAccount.connect(asEntrypoint).installModule(this.moduleType, this.mock.target, this.installData), + ); + }); + + describe('execute', function () { + beforeEach(function () { + this.args = [42, '0x1234']; + this.data = this.target.interface.encodeFunctionData('mockFunctionWithArgs', this.args); + this.calldata = encodeSingle(this.target, 0, this.data); + }); + + it('succeeds if called by the account', async function () { + await expect(this.mockFromAccount.execute(this.mockAccount.address, mode, this.calldata, ethers.ZeroHash)) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(...this.args); + }); + + it('reverts with ERC7579UnauthorizedExecution if called by an authorized sender', async function () { + await expect( + this.mock.execute(this.mockAccount.address, mode, this.calldata, ethers.ZeroHash), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); + }); + }); + }); +} + module.exports = { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Validator, + shouldBehaveLikeERC7579Executor, }; From ea522736c3fbc8875047b3f7a109a1c953606baa Mon Sep 17 00:00:00 2001 From: ernestognw Date: Sun, 11 May 2025 19:12:10 -0600 Subject: [PATCH 61/90] Moar tests --- .../modules/ERC7579DelayedExecutor.sol | 10 +- .../modules/ERC7579DelayedExecutor.test.js | 164 ++++++++++++++++++ test/account/modules/ERC7579Executor.test.js | 52 +++++- .../account/modules/ERC7579Module.behavior.js | 42 ----- 4 files changed, 210 insertions(+), 58 deletions(-) create mode 100644 test/account/modules/ERC7579DelayedExecutor.test.js diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 94f51f1c..054dbbfb 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -69,9 +69,6 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Emitted when a new operation is canceled. event ERC7579ExecutorOperationCanceled(address indexed account, bytes32 indexed operationId); - /// @dev Emitted when a new operation is executed. - event ERC7579ExecutorOperationExecuted(address indexed account, bytes32 indexed operationId); - /// @dev Emitted when the execution delay is updated. event ERC7579ExecutorDelayUpdated(address indexed account, uint32 newDelay, uint48 effectTime); @@ -298,9 +295,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Operations are uniquely identified by the combination of `mode`, `executionCalldata`, and `salt`. * See {canSchedule} for authorization checks. */ - function schedule(Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - require(canSchedule(msg.sender, mode, executionCalldata, salt), ERC7579UnauthorizedSchedule()); - _schedule(msg.sender, mode, executionCalldata, salt); + function schedule(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { + require(canSchedule(account, mode, executionCalldata, salt), ERC7579UnauthorizedSchedule()); + _schedule(account, mode, executionCalldata, salt); } /** @@ -410,7 +407,6 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { _schedules[id].executed = true; - emit ERC7579ExecutorOperationExecuted(account, id); return super._execute(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..5d4ac01e --- /dev/null +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -0,0 +1,164 @@ +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 { + 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() { + // 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(15); + 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 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, + delay, + expiration, + }; +} + +describe('ERC7579DelayedExecutor', function () { + const salt = ethers.ZeroHash; + + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeERC7579Module(); + + describe('scheduling', function () { + it('schedules an operation if called by the account', async function () { + const id = this.mock.hashOperation(this.mockAccount.address, this.mode, this.calldata, salt); + const tx = await this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt); + const now = await time.latest(); + await expect(tx) + .to.emit(this.mock, 'ERC7579ExecutorOperationScheduled') + .withArgs(this.mockAccount.address, id, this.mode, this.calldata, salt, now); + await expect( + this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't schedule twice + }); + + it('reverts with ERC7579UnauthorizedSchedule if called by other account', async function () { + await expect( + this.mock.schedule(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedSchedule'); + }); + }); + + describe('execution', function () { + beforeEach(async function () { + await this.mock.$_schedule(this.mockAccount.address, this.mode, this.calldata, salt); + }); + + it('reverts with ERC7579UnauthorizedExecution before delay passes with any caller', async function () { + await expect( + this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); + }); + + it('reverts with ERC7579UnauthorizedExecution before delay passes with the account as caller', async function () { + await expect( + this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt), + ).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, this.mode, this.calldata, salt)) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(...this.args); + await expect( + this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); // 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, this.mode, this.calldata, salt)) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(...this.args); + await expect( + this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't execute twice + }); + + it('reverts 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, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); + }); + + 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, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Allowed, expired + }); + }); + + describe('cancelling', function () { + beforeEach(async function () { + await this.mock.$_schedule(this.mockAccount.address, this.mode, this.calldata, salt); + }); + + it('cancels an operation if called by the account', async function () { + const id = this.mock.hashOperation(this.mockAccount.address, this.mode, this.calldata, salt); + await expect(this.mockFromAccount.cancel(this.mockAccount.address, this.mode, this.calldata, salt)) + .to.emit(this.mock, 'ERC7579ExecutorOperationCanceled') + .withArgs(this.mockAccount.address, id); + await expect( + this.mockFromAccount.cancel(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't cancel twice + }); + + it('reverts with ERC7579UnauthorizedCancellation if called by other account', async function () { + await expect( + this.mock.cancel(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedCancellation'); + }); + }); +}); diff --git a/test/account/modules/ERC7579Executor.test.js b/test/account/modules/ERC7579Executor.test.js index 9c349dbb..9337e296 100644 --- a/test/account/modules/ERC7579Executor.test.js +++ b/test/account/modules/ERC7579Executor.test.js @@ -1,14 +1,19 @@ -const { ethers } = require('hardhat'); +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 } = require('@openzeppelin/contracts/test/helpers/erc7579'); -const { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Executor } = require('./ERC7579Module.behavior'); +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() { - const [other] = await ethers.getSigners(); - // Deploy ERC-7579 validator module const mock = await ethers.deployContract('$ERC7579ExecutorMock'); const target = await ethers.deployContract('CallReceiverMockExtended'); @@ -24,22 +29,51 @@ async function fixture() { 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: MODULE_TYPE_EXECUTOR, + moduleType, mock, mockAccount, mockFromAccount, - other, target, installData, + args, + data, + calldata, + mode, }; } -describe('ERC7579Validator', function () { +describe('ERC7579Executor', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); + describe('execute', function () { + it('succeeds if called by the account', async function () { + await expect(this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, ethers.ZeroHash)) + .to.emit(this.mock, 'ERC7579ExecutorOperationExecuted') + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .withArgs(...this.args); + }); + + it('reverts with ERC7579UnauthorizedExecution if called by an authorized sender', async function () { + await expect( + this.mock.execute(this.mockAccount.address, this.mode, this.calldata, ethers.ZeroHash), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); + }); + }); + shouldBehaveLikeERC7579Module(); - shouldBehaveLikeERC7579Executor(); }); diff --git a/test/account/modules/ERC7579Module.behavior.js b/test/account/modules/ERC7579Module.behavior.js index 447e2e23..7645c4ba 100644 --- a/test/account/modules/ERC7579Module.behavior.js +++ b/test/account/modules/ERC7579Module.behavior.js @@ -1,14 +1,6 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('@openzeppelin/contracts/test/helpers/erc4337'); -const { - encodeMode, - encodeSingle, - CALL_TYPE_CALL, - EXEC_TYPE_DEFAULT, -} = require('@openzeppelin/contracts/test/helpers/erc7579'); -const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); -const { entrypoint } = require('hardhat'); function shouldBehaveLikeERC7579Module() { describe('behaves like ERC7579Module', function () { @@ -72,41 +64,7 @@ function shouldBehaveLikeERC7579Validator() { }); } -function shouldBehaveLikeERC7579Executor() { - describe('behaves like ERC7579Executor', function () { - const mode = encodeMode({ callType: CALL_TYPE_CALL, execType: EXEC_TYPE_DEFAULT }); - - beforeEach(async function () { - await this.mockAccount.deploy(); - await impersonate(entrypoint.v08.target).then(asEntrypoint => - this.mockAccount.connect(asEntrypoint).installModule(this.moduleType, this.mock.target, this.installData), - ); - }); - - describe('execute', function () { - beforeEach(function () { - this.args = [42, '0x1234']; - this.data = this.target.interface.encodeFunctionData('mockFunctionWithArgs', this.args); - this.calldata = encodeSingle(this.target, 0, this.data); - }); - - it('succeeds if called by the account', async function () { - await expect(this.mockFromAccount.execute(this.mockAccount.address, mode, this.calldata, ethers.ZeroHash)) - .to.emit(this.target, 'MockFunctionCalledWithArgs') - .withArgs(...this.args); - }); - - it('reverts with ERC7579UnauthorizedExecution if called by an authorized sender', async function () { - await expect( - this.mock.execute(this.mockAccount.address, mode, this.calldata, ethers.ZeroHash), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); - }); - }); - }); -} - module.exports = { shouldBehaveLikeERC7579Module, shouldBehaveLikeERC7579Validator, - shouldBehaveLikeERC7579Executor, }; From 563dfe535544f7e3487826723dbb29964ca81025 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 12:44:51 -0600 Subject: [PATCH 62/90] Add expiration to ERC7579MultisigConfirmation --- .../modules/ERC7579MultisigConfirmation.sol | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/contracts/account/modules/ERC7579MultisigConfirmation.sol b/contracts/account/modules/ERC7579MultisigConfirmation.sol index 18fc2c54..1675a10c 100644 --- a/contracts/account/modules/ERC7579MultisigConfirmation.sol +++ b/contracts/account/modules/ERC7579MultisigConfirmation.sol @@ -17,14 +17,18 @@ import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; * explicitly agreed to their roles. */ abstract contract ERC7579MultisigConfirmation is ERC7579Multisig, EIP712 { - bytes32 private constant MULTISIG_CONFIRMATION = keccak256("MultisigConfirmation(address account,address module)"); + 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) internal view virtual returns (bytes32) { - return _hashTypedDataV4(keccak256(abi.encode(MULTISIG_CONFIRMATION, account, address(this)))); + function _signableConfirmationHash(address account, uint256 deadline) internal view virtual returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(MULTISIG_CONFIRMATION, account, address(this), deadline))); } /** @@ -32,7 +36,7 @@ abstract contract ERC7579MultisigConfirmation is ERC7579Multisig, EIP712 { * Each entry in newSigners must be ABI-encoded as: * * ```solidity - * abi.encode(bytes signer, bytes signature) + * abi.encode(deadline,signer,signature); // uint256, bytes, bytes * ``` * * * signer: The ERC-7913 signer to add @@ -44,9 +48,13 @@ abstract contract ERC7579MultisigConfirmation is ERC7579Multisig, EIP712 { function _addSigners(address account, bytes[] memory newSigners) internal virtual override { uint256 newSignersLength = newSigners.length; for (uint256 i = 0; i < newSignersLength; i++) { - (bytes memory signer, bytes memory signature) = abi.decode(newSigners[i], (bytes, bytes)); + (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), signature), + ERC7913Utils.isValidSignatureNow(signer, _signableConfirmationHash(account, deadline), signature), ERC7579MultisigInvalidConfirmationSignature(signer) ); newSigners[i] = signer; From 76b4b63d9f516c31c1efb8ac64a72c4ad3218111 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 18:15:06 -0600 Subject: [PATCH 63/90] Update ERC7579DelayedExecutor --- .../modules/ERC7579DelayedExecutor.sol | 96 +++++++------------ .../modules/ERC7579DelayedExecutor.test.js | 5 +- 2 files changed, 39 insertions(+), 62 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 054dbbfb..7cc26e84 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.27; import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; import {IERC7579ModuleConfig, MODULE_TYPE_EXECUTOR} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; import {ERC7579Executor} from "./ERC7579Executor.sol"; @@ -15,12 +14,9 @@ import {ERC7579Executor} from "./ERC7579Executor.sol"; * period has elapsed (indicated during {onInstall}), creating a security window where suspicious * operations can be monitored and potentially canceled (see {cancel}) before execution (see {execute}). * - * Accounts can customize their delay periods with {setDelay}, Delay changes take effect after a - * transition period to prevent immediate security downgrades. - * - * Operations have an expiration mechanism that prevents them from being executed after a certain - * time has passed. It can be customized by overriding the {expiration} function and defaults to - * `type(uint32).max` (no expiration). + * Accounts can customize their delay periods with {setDelay}. Changes take effect after a transition + * period to prevent immediate security downgrades. Operations have an expiration mechanism that + * prevents them from being executed after a certain time has passed. * * IMPORTANT: This module assumes the {AccountERC7579} is the ultimate authority and does not restrict * module uninstallation. An account can bypass the time-delay security by simply uninstalling @@ -30,7 +26,6 @@ import {ERC7579Executor} from "./ERC7579Executor.sol"; abstract contract ERC7579DelayedExecutor is ERC7579Executor { using Time for *; - // Invariant `delay` <= `expiration` < `type(uint32).max - 1` (for NO_DELAY and EXECUTED) struct Schedule { // 1 slot = 48 + 32 + 32 + 1 + 1 = 114 bits ~ 14 bytes uint48 scheduledAt; // The time when the operation was scheduled @@ -43,7 +38,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { struct ExecutionConfig { // 1 slot = 112 + 32 + 1 + 1 = 146 bits ~ 18 bytes Time.Delay delay; - uint32 expiration; + uint32 expiration; // Time after operation is OperationState.Ready to expire bool installed; } @@ -106,14 +101,14 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return state(hashOperation(account, mode, executionCalldata, salt)); } - /// @dev Same as {state}, but for a specific operation. + /// @dev Same as {state}, but for a specific operation id. function state(bytes32 operationId) public view returns (OperationState) { - Schedule storage sched = _schedules[operationId]; - if (sched.scheduledAt == 0) return OperationState.Unknown; - if (sched.canceled) return OperationState.Canceled; - if (sched.executed) return OperationState.Executed; - if (block.timestamp < sched.scheduledAt + sched.executableAfter) return OperationState.Scheduled; - if (block.timestamp > sched.scheduledAt + sched.expiresAfter) return OperationState.Expired; + 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; } @@ -124,8 +119,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes calldata executionCalldata, bytes32 salt ) public view virtual override returns (bool) { - bytes32 id = hashOperation(account, mode, executionCalldata, salt); - return state(id) == OperationState.Ready || super.canExecute(account, mode, executionCalldata, salt); + return + state(account, mode, executionCalldata, salt) == OperationState.Ready || + super.canExecute(account, mode, executionCalldata, salt); } /** @@ -157,7 +153,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { } /** - * @dev Whether the caller is authorized to cancel operations. + * @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. * @@ -184,36 +180,21 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return account == msg.sender; } - /// @dev Minimum delay for operations. Default for accounts that do not set a custom delay. - function minimumDelay() public view virtual returns (uint32) { + /// @dev Minimum delay after which {setDelay} takes effect. + function minSetback() public view virtual returns (uint32) { return 1 days; // Up to ~136 years } - /// @dev Minimum expiration for operations. Default for accounts that do not set a custom expiration. - function minimumExpiration() public view virtual returns (uint32) { - return 365 days; // Up to ~136 years - } - - /// @dev Delay for a specific account. If not set, returns the minimum delay. + /// @dev Delay for a specific account. function getDelay( address account ) public view virtual returns (uint32 delay, uint32 pendingDelay, uint48 effectTime) { - (uint32 currentDelay, uint32 newDelay, uint48 effect) = _config[account].delay.getFull(); - return ( - // Safe downcast since both arguments are uint32 - uint32(Math.ternary(!_config[account].installed, 0, Math.max(currentDelay, minimumDelay()))), - newDelay, - effect - ); + return _config[account].delay.getFull(); } - /// @dev Expiration delay for account operations. If not set, returns the minimum delay. + /// @dev Expiration delay for account operations. function getExpiration(address account) public view virtual returns (uint32 expiration) { - // Safe downcast since both arguments are uint32 - return - uint32( - Math.ternary(!_config[account].installed, 0, Math.max(_config[account].expiration, minimumExpiration())) - ); + return _config[account].expiration; } /// @dev Schedule for an operation. Returns default values if not set (i.e. `uint48(0)`, `uint48(0)`, `uint48(0)`). @@ -231,11 +212,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes32 operationId ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt) { scheduledAt = _schedules[operationId].scheduledAt; - return ( - scheduledAt, - scheduledAt + _schedules[operationId].executableAfter, - scheduledAt + _schedules[operationId].expiresAfter - ); + executableAt = scheduledAt + _schedules[operationId].executableAfter; + return (scheduledAt, executableAt, executableAt + _schedules[operationId].expiresAfter); } /// @dev Returns the operation id. @@ -269,7 +247,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) : (0, 0); - _setDelay(msg.sender, initialDelay); + // 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); } } @@ -278,11 +258,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * @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 minimum delay, whichever is longer. This prevents immediate security downgrades. + * 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); + _setDelay(msg.sender, newDelay, minSetback()); } /// @dev Allows an account to update its execution expiration (see {getExpiration}). @@ -311,8 +291,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /** * @dev Cleans up the {getDelay} and {getExpiration} values by scheduling them to `0` - * and respecting the previous delay and expiration values. Do not consider {minimumDelay} and - * {minimumExpiration} for scheduling. + * 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 @@ -326,7 +305,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function onUninstall(bytes calldata) public virtual { _config[msg.sender].installed = false; - _unsafeSetDelay(msg.sender, 0, 0); + _setDelay(msg.sender, 0, minSetback()); // Avoids immediate downgrades _setExpiration(msg.sender, 0); } @@ -335,8 +314,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Emits an {ERC7579ExecutorDelayUpdated} event. */ - function _setDelay(address account, uint32 newDelay) internal virtual { - _unsafeSetDelay(account, newDelay, minimumDelay()); + function _setDelay(address account, uint32 newDelay, uint32 setback) internal virtual { + (uint32 delay, uint32 pendingDelay, uint48 effectTime) = getDelay(account); + uint48 effect; + (_config[account].delay, effect) = Time.pack(delay, pendingDelay, effectTime).withUpdate(newDelay, setback); + emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); } /** @@ -350,14 +332,6 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { emit ERC7579ExecutorExpirationUpdated(account, newExpiration); } - /// @dev Version of {_setDelay} without `type(uint32).max` check and with a custom minimum setback. - function _unsafeSetDelay(address account, uint32 newDelay, uint32 minSetback) internal virtual { - (uint32 delay, uint32 pendingDelay, uint48 effectTime) = getDelay(account); - uint48 effect; - (_config[account].delay, effect) = Time.pack(delay, pendingDelay, effectTime).withUpdate(newDelay, minSetback); - emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); - } - /** * @dev Internal version of {schedule} that takes an `account` address as an argument. * diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index 5d4ac01e..54404e15 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -24,7 +24,7 @@ async function fixture() { // Prepare module installation data const delay = time.duration.days(10); - const expiration = time.duration.years(15); + const expiration = time.duration.years(1); const installData = ethers.AbiCoder.defaultAbiCoder().encode(['uint32', 'uint32'], [delay, expiration]); // ERC-7579 account @@ -79,6 +79,9 @@ describe('ERC7579DelayedExecutor', function () { await expect( this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't schedule twice + await expect( + this.mock.getSchedule(this.mockAccount.address, this.mode, this.calldata, salt), + ).to.eventually.deep.equal([now, now + this.delay, now + this.delay + this.expiration]); }); it('reverts with ERC7579UnauthorizedSchedule if called by other account', async function () { From aae8d449d5dad9a03459f26a37f3b8f1050b745c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 19:22:01 -0600 Subject: [PATCH 64/90] More tests for ERC7579DelayedExecutor --- .../modules/ERC7579DelayedExecutor.sol | 5 +- .../modules/ERC7579DelayedExecutor.test.js | 63 ++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 7cc26e84..bd553380 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -314,10 +314,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Emits an {ERC7579ExecutorDelayUpdated} event. */ - function _setDelay(address account, uint32 newDelay, uint32 setback) internal virtual { - (uint32 delay, uint32 pendingDelay, uint48 effectTime) = getDelay(account); + function _setDelay(address account, uint32 newDelay, uint32 minimumSetback) internal virtual { uint48 effect; - (_config[account].delay, effect) = Time.pack(delay, pendingDelay, effectTime).withUpdate(newDelay, setback); + (_config[account].delay, effect) = _config[account].delay.withUpdate(newDelay, minimumSetback); emit ERC7579ExecutorDelayUpdated(account, newDelay, effect); } diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index 54404e15..8dfbcef7 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -30,13 +30,13 @@ async function fixture() { // 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 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); @@ -48,6 +48,7 @@ async function fixture() { mock, mockAccount, mockFromAccount, + mockAccountFromEntrypoint, target, installData, args, @@ -68,7 +69,61 @@ describe('ERC7579DelayedExecutor', function () { shouldBehaveLikeERC7579Module(); + 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); + }); + + it('schedule delay unset and unsets expiration', 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, this.mode, this.calldata, salt); const tx = await this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt); @@ -93,6 +148,7 @@ describe('ERC7579DelayedExecutor', function () { describe('execution', function () { beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); await this.mock.$_schedule(this.mockAccount.address, this.mode, this.calldata, salt); }); @@ -145,6 +201,7 @@ describe('ERC7579DelayedExecutor', function () { describe('cancelling', function () { beforeEach(async function () { + await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); await this.mock.$_schedule(this.mockAccount.address, this.mode, this.calldata, salt); }); From 8e7f2ae2270ea1a0089e4a0773be549f91223548 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 19:30:47 -0600 Subject: [PATCH 65/90] 100% coverage delayed executor --- .../account/modules/ERC7579DelayedExecutor.sol | 12 +++++++++++- .../modules/ERC7579DelayedExecutor.test.js | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index bd553380..eaaba907 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -226,6 +226,16 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return keccak256(abi.encode(account, mode, executionCalldata, salt)); } + /// @dev Default delay for account operations. Set if not provided during {onInstall}. + function defaultDelay() public view virtual returns (uint32) { + return 5 days; + } + + /// @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. @@ -246,7 +256,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { _config[msg.sender].installed = true; (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) - : (0, 0); + : (defaultDelay(), 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); diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index 8dfbcef7..7ea7ef94 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -77,6 +77,22 @@ describe('ERC7579DelayedExecutor', function () { .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', async function () { From e312d0bc9aee447fb2ccaedc0ff6cd4ca2463001 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 20:28:51 -0600 Subject: [PATCH 66/90] Moar tests --- .../modules/ERC7579MultisigExecutorMock.sol | 9 + test/account/modules/ERC7579Multisig.test.js | 279 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 contracts/mocks/account/modules/ERC7579MultisigExecutorMock.sol create mode 100644 test/account/modules/ERC7579Multisig.test.js diff --git a/contracts/mocks/account/modules/ERC7579MultisigExecutorMock.sol b/contracts/mocks/account/modules/ERC7579MultisigExecutorMock.sol new file mode 100644 index 00000000..4fc69b02 --- /dev/null +++ b/contracts/mocks/account/modules/ERC7579MultisigExecutorMock.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7579Executor} from "../../../account/modules/ERC7579Executor.sol"; +import {ERC7579Multisig} from "../../../account/modules/ERC7579Multisig.sol"; +import {MODULE_TYPE_EXECUTOR, IERC7579Hook} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; + +abstract contract ERC7579MultisigExecutorMock is ERC7579Executor, ERC7579Multisig {} diff --git a/test/account/modules/ERC7579Multisig.test.js b/test/account/modules/ERC7579Multisig.test.js new file mode 100644 index 00000000..6900dee4 --- /dev/null +++ b/test/account/modules/ERC7579Multisig.test.js @@ -0,0 +1,279 @@ +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, + 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'); + 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; + + // 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, + }; +} + +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 valid signatures meeting threshold', async function () { + // Create valid signature from authorized signer + const signature1 = await signerECDSA1.signMessage('test'); + + // Prepare signers and signatures arrays + const signers = [signerECDSA1.address]; + const signatures = [signature1]; + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should succeed with valid signature meeting threshold + await expect( + this.mock.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), + ).to.not.be.reverted; + }); + + it('rejects signatures not meeting threshold', async function () { + // First set threshold to 2 + await this.mockFromAccount.setThreshold(2); + + // Create valid signature from authorized signer + const signature1 = await signerECDSA1.signMessage('test'); + + // Prepare signers and signatures arrays + const signers = [signerECDSA1.address]; + const signatures = [signature1]; + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because threshold is 2 but only 1 signature provided + await expect( + this.mock.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + }); + + it('rejects signatures from unauthorized signers', async function () { + // Create valid signature from unauthorized signer + const signature = await signerECDSA4.signMessage('test'); + + // Prepare signers and signatures arrays + const signers = [signerECDSA4.address]; + const signatures = [signature]; + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because signer is not authorized + await expect( + this.mock.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + }); + + it('rejects invalid signatures from authorized signers', async function () { + // Create invalid signature (signing different message) + const invalidSignature = await signerECDSA1.signMessage('Different message'); + + // Prepare signers and signatures arrays + const signers = [signerECDSA1.address]; + const signatures = [invalidSignature]; + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because signature is invalid for the given hash + await expect( + this.mock.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + }); + }); +}); From da9ff1ff10e5a6b95eef518cca17a57dc14fec98 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 21:19:30 -0600 Subject: [PATCH 67/90] Moar tests --- ...ecutorMock.sol => ERC7579MultisigMock.sol} | 2 + scripts/checks/coverage.sh | 2 +- test/account/modules/ERC7579Multisig.test.js | 92 +++-- .../modules/ERC7579MultisigWeighted.test.js | 367 ++++++++++++++++++ 4 files changed, 419 insertions(+), 44 deletions(-) rename contracts/mocks/account/modules/{ERC7579MultisigExecutorMock.sol => ERC7579MultisigMock.sol} (67%) create mode 100644 test/account/modules/ERC7579MultisigWeighted.test.js diff --git a/contracts/mocks/account/modules/ERC7579MultisigExecutorMock.sol b/contracts/mocks/account/modules/ERC7579MultisigMock.sol similarity index 67% rename from contracts/mocks/account/modules/ERC7579MultisigExecutorMock.sol rename to contracts/mocks/account/modules/ERC7579MultisigMock.sol index 4fc69b02..e653f5a1 100644 --- a/contracts/mocks/account/modules/ERC7579MultisigExecutorMock.sol +++ b/contracts/mocks/account/modules/ERC7579MultisigMock.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.27; import {ERC7579Executor} from "../../../account/modules/ERC7579Executor.sol"; import {ERC7579Multisig} from "../../../account/modules/ERC7579Multisig.sol"; +import {ERC7579MultisigWeighted} from "../../../account/modules/ERC7579MultisigWeighted.sol"; import {MODULE_TYPE_EXECUTOR, IERC7579Hook} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; abstract contract ERC7579MultisigExecutorMock is ERC7579Executor, ERC7579Multisig {} +abstract contract ERC7579MultisigWeightedExecutorMock is ERC7579Executor, ERC7579MultisigWeighted {} diff --git a/scripts/checks/coverage.sh b/scripts/checks/coverage.sh index a591069c..f658dca6 100755 --- a/scripts/checks/coverage.sh +++ b/scripts/checks/coverage.sh @@ -6,7 +6,7 @@ export COVERAGE=true export FOUNDRY_FUZZ_RUNS=10 # Hardhat coverage -hardhat coverage +hardhat coverage --testfiles test/account/modules/**/*.test.js if [ "${CI:-"false"}" == "true" ]; then # Foundry coverage diff --git a/test/account/modules/ERC7579Multisig.test.js b/test/account/modules/ERC7579Multisig.test.js index 6900dee4..33b02c23 100644 --- a/test/account/modules/ERC7579Multisig.test.js +++ b/test/account/modules/ERC7579Multisig.test.js @@ -3,6 +3,7 @@ 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, @@ -31,6 +32,7 @@ async function fixture() { // 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]); @@ -65,6 +67,7 @@ async function fixture() { mode, signers, threshold, + multiSigner, }; } @@ -205,74 +208,77 @@ describe('ERC7579Multisig', function () { await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); }); - it('validates valid signatures meeting threshold', async function () { - // Create valid signature from authorized signer - const signature1 = await signerECDSA1.signMessage('test'); - - // Prepare signers and signatures arrays - const signers = [signerECDSA1.address]; - const signatures = [signature1]; - - // Encode the multi-signature - const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + it('validates multiple signatures meeting threshold', async function () { + // Set threshold to 2 + await this.mockFromAccount.setThreshold(2); - // Should succeed with valid signature meeting threshold - await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), - ).to.not.be.reverted; + // Create hash and sign it + const testMessage = 'test'; + const messageHash = ethers.hashMessage(testMessage); + const multiSignature = await this.multiSigner.signMessage(testMessage); + await this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature); + // Should succeed with valid signatures meeting threshold + await expect(this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature)).to.not.be + .reverted; }); it('rejects signatures not meeting threshold', async function () { // First set threshold to 2 await this.mockFromAccount.setThreshold(2); - // Create valid signature from authorized signer - const signature1 = await signerECDSA1.signMessage('test'); + // Create MultiERC7913SigningKey with one authorized signer + const multiSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1])); - // Prepare signers and signatures arrays - const signers = [signerECDSA1.address]; - const signatures = [signature1]; - - // Encode the multi-signature - const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + // 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.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), + this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature), ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); }); - it('rejects signatures from unauthorized signers', async function () { - // Create valid signature from unauthorized signer - const signature = await signerECDSA4.signMessage('test'); + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature)).to.not.be + .reverted; + }); - // Prepare signers and signatures arrays - const signers = [signerECDSA4.address]; - const signatures = [signature]; + it('rejects signatures from unauthorized signers', async function () { + // Create MultiERC7913SigningKey with unauthorized signer + const multiSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA4])); - // Encode the multi-signature - const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + // 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.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), + this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature), ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); }); it('rejects invalid signatures from authorized signers', async function () { - // Create invalid signature (signing different message) - const invalidSignature = await signerECDSA1.signMessage('Different message'); - - // Prepare signers and signatures arrays - const signers = [signerECDSA1.address]; - const signatures = [invalidSignature]; - - // Encode the multi-signature - const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + // 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 invalid for the given hash + // Should fail because signature is for a different hash await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, ethers.hashMessage('test'), multiSignature), + this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature), ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); }); }); diff --git a/test/account/modules/ERC7579MultisigWeighted.test.js b/test/account/modules/ERC7579MultisigWeighted.test.js new file mode 100644 index 00000000..535ffaf7 --- /dev/null +++ b/test/account/modules/ERC7579MultisigWeighted.test.js @@ -0,0 +1,367 @@ +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'); + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, exactSignature)).to.not.be + .reverted; + + // Also works with all signers (1+2+3=6 > threshold 3) + const sufficientSignature = await this.multiSigner.signMessage(testMessage); + + await expect(this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, sufficientSignature)).to.not + .be.reverted; + + // 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.$_checkMultiSignature(this.mockAccount.address, messageHash, minimumSignature)).to.not.be + .reverted; + }); + + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, insufficientSignature), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + }); + + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, insufficientSignature), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + + // 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.$_checkMultiSignature(this.mockAccount.address, messageHash, insufficientSignature)).to.not + .be.reverted; + }); + + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, invalidSignature), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + }); + }); +}); From 5ec34e09dfe08b8733f4b687dd00c26f3de3a422 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 21:43:50 -0600 Subject: [PATCH 68/90] Moar tests --- .../modules/ERC7579MultisigConfirmation.sol | 2 +- .../account/modules/ERC7579MultisigMock.sol | 2 + scripts/checks/coverage.sh | 2 +- .../ERC7579MultisigConfirmation.test.js | 257 ++++++++++++++++++ test/helpers/eip712-types.js | 5 + 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 test/account/modules/ERC7579MultisigConfirmation.test.js diff --git a/contracts/account/modules/ERC7579MultisigConfirmation.sol b/contracts/account/modules/ERC7579MultisigConfirmation.sol index 1675a10c..8152b7a4 100644 --- a/contracts/account/modules/ERC7579MultisigConfirmation.sol +++ b/contracts/account/modules/ERC7579MultisigConfirmation.sol @@ -57,7 +57,7 @@ abstract contract ERC7579MultisigConfirmation is ERC7579Multisig, EIP712 { ERC7913Utils.isValidSignatureNow(signer, _signableConfirmationHash(account, deadline), signature), ERC7579MultisigInvalidConfirmationSignature(signer) ); - newSigners[i] = signer; + newSigners[i] = signer; // Replace the ABI-encoded value with the signer } super._addSigners(account, newSigners); } diff --git a/contracts/mocks/account/modules/ERC7579MultisigMock.sol b/contracts/mocks/account/modules/ERC7579MultisigMock.sol index e653f5a1..89688ddf 100644 --- a/contracts/mocks/account/modules/ERC7579MultisigMock.sol +++ b/contracts/mocks/account/modules/ERC7579MultisigMock.sol @@ -5,7 +5,9 @@ pragma solidity ^0.8.27; 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"; abstract contract ERC7579MultisigExecutorMock is ERC7579Executor, ERC7579Multisig {} abstract contract ERC7579MultisigWeightedExecutorMock is ERC7579Executor, ERC7579MultisigWeighted {} +abstract contract ERC7579MultisigConfirmationMock is ERC7579Executor, ERC7579MultisigConfirmation {} diff --git a/scripts/checks/coverage.sh b/scripts/checks/coverage.sh index f658dca6..a591069c 100755 --- a/scripts/checks/coverage.sh +++ b/scripts/checks/coverage.sh @@ -6,7 +6,7 @@ export COVERAGE=true export FOUNDRY_FUZZ_RUNS=10 # Hardhat coverage -hardhat coverage --testfiles test/account/modules/**/*.test.js +hardhat coverage if [ "${CI:-"false"}" == "true" ]; then # Foundry coverage diff --git a/test/account/modules/ERC7579MultisigConfirmation.test.js b/test/account/modules/ERC7579MultisigConfirmation.test.js new file mode 100644 index 00000000..39bbe59d --- /dev/null +++ b/test/account/modules/ERC7579MultisigConfirmation.test.js @@ -0,0 +1,257 @@ +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('$ERC7579MultisigConfirmationMock', ['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/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, ); From 94cb88c34d9dd74a424aed839e3628c9160f73ad Mon Sep 17 00:00:00 2001 From: ernestognw Date: Mon, 12 May 2025 21:46:42 -0600 Subject: [PATCH 69/90] nits --- .../mocks/account/modules/ERC7579ExecutorMock.sol | 12 ------------ .../mocks/account/modules/ERC7579MultisigMock.sol | 2 +- test/account/modules/ERC7579Executor.test.js | 2 +- .../modules/ERC7579MultisigConfirmation.test.js | 5 ++++- 4 files changed, 6 insertions(+), 15 deletions(-) delete mode 100644 contracts/mocks/account/modules/ERC7579ExecutorMock.sol diff --git a/contracts/mocks/account/modules/ERC7579ExecutorMock.sol b/contracts/mocks/account/modules/ERC7579ExecutorMock.sol deleted file mode 100644 index f62e5646..00000000 --- a/contracts/mocks/account/modules/ERC7579ExecutorMock.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.27; - -import {ERC7579Executor} from "../../../account/modules/ERC7579Executor.sol"; -import {MODULE_TYPE_EXECUTOR, IERC7579Hook} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; - -abstract contract ERC7579ExecutorMock is ERC7579Executor { - function onInstall(bytes calldata data) public view {} - - function onUninstall(bytes calldata data) public view {} -} diff --git a/contracts/mocks/account/modules/ERC7579MultisigMock.sol b/contracts/mocks/account/modules/ERC7579MultisigMock.sol index 89688ddf..e604330a 100644 --- a/contracts/mocks/account/modules/ERC7579MultisigMock.sol +++ b/contracts/mocks/account/modules/ERC7579MultisigMock.sol @@ -10,4 +10,4 @@ import {MODULE_TYPE_EXECUTOR, IERC7579Hook} from "@openzeppelin/contracts/interf abstract contract ERC7579MultisigExecutorMock is ERC7579Executor, ERC7579Multisig {} abstract contract ERC7579MultisigWeightedExecutorMock is ERC7579Executor, ERC7579MultisigWeighted {} -abstract contract ERC7579MultisigConfirmationMock is ERC7579Executor, ERC7579MultisigConfirmation {} +abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ERC7579MultisigConfirmation {} diff --git a/test/account/modules/ERC7579Executor.test.js b/test/account/modules/ERC7579Executor.test.js index 9337e296..f938d403 100644 --- a/test/account/modules/ERC7579Executor.test.js +++ b/test/account/modules/ERC7579Executor.test.js @@ -15,7 +15,7 @@ const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); async function fixture() { // Deploy ERC-7579 validator module - const mock = await ethers.deployContract('$ERC7579ExecutorMock'); + const mock = await ethers.deployContract('$ERC7579MultisigExecutorMock'); const target = await ethers.deployContract('CallReceiverMockExtended'); // ERC-4337 env diff --git a/test/account/modules/ERC7579MultisigConfirmation.test.js b/test/account/modules/ERC7579MultisigConfirmation.test.js index 39bbe59d..75b0e1cb 100644 --- a/test/account/modules/ERC7579MultisigConfirmation.test.js +++ b/test/account/modules/ERC7579MultisigConfirmation.test.js @@ -15,7 +15,10 @@ const signerToConfirm = ethers.Wallet.createRandom(); async function fixture() { // Deploy ERC-7579 multisig confirmation module - const mock = await ethers.deployContract('$ERC7579MultisigConfirmationMock', ['ERC7579MultisigConfirmation', '1']); + const mock = await ethers.deployContract('$ERC7579MultisigConfirmationExecutorMock', [ + 'ERC7579MultisigConfirmation', + '1', + ]); // ERC-4337 env const helper = new ERC4337Helper(); From 44228338f0ac9607d4d111364c471c7d43d09a6f Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 13 May 2025 17:46:35 -0600 Subject: [PATCH 70/90] Add executors to docs --- contracts/account/README.adoc | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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}} From 3e05f715789c68856765494a905def2b0f9b3a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Tue, 13 May 2025 17:53:54 -0600 Subject: [PATCH 71/90] Apply suggestions from code review Co-authored-by: Gonzalo Othacehe <86085168+gonzaotc@users.noreply.github.com> --- contracts/account/modules/ERC7579DelayedExecutor.sol | 6 +++--- test/account/modules/ERC7579DelayedExecutor.test.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index eaaba907..060cb568 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -36,7 +36,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { } struct ExecutionConfig { - // 1 slot = 112 + 32 + 1 + 1 = 146 bits ~ 18 bytes + // 1 slot = 112 + 32 + 1 = 145 bits ~ 18 bytes Time.Delay delay; uint32 expiration; // Time after operation is OperationState.Ready to expire bool installed; @@ -83,10 +83,10 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { ); /// @dev The operation is not authorized to be canceled. - error ERC7579UnauthorizedCancellation(); + error ERC7579ExecutorUnauthorizedCancellation(); /// @dev The operation is not authorized to be scheduled. - error ERC7579UnauthorizedSchedule(); + error ERC7579ExecutorUnauthorizedSchedule(); mapping(address account => ExecutionConfig) private _config; mapping(bytes32 operationId => Schedule) private _schedules; diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index 7ea7ef94..a9f49f73 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -95,7 +95,7 @@ describe('ERC7579DelayedExecutor', function () { .withArgs(this.mockAccount.address, time.duration.days(60)); }); - it('schedule delay unset and unsets expiration', async function () { + 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(); From 7c89c89f78c55b2f9895ae008080b3297ebbd5fc Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 13 May 2025 17:54:29 -0600 Subject: [PATCH 72/90] fix --- contracts/account/modules/ERC7579DelayedExecutor.sol | 8 ++++---- .../{ERC7579MultisigMock.sol => ERC7579MultisigMocks.sol} | 0 test/account/modules/ERC7579DelayedExecutor.test.js | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) rename contracts/mocks/account/modules/{ERC7579MultisigMock.sol => ERC7579MultisigMocks.sol} (100%) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 060cb568..43b029e5 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -286,7 +286,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * See {canSchedule} for authorization checks. */ function schedule(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - require(canSchedule(account, mode, executionCalldata, salt), ERC7579UnauthorizedSchedule()); + require(canSchedule(account, mode, executionCalldata, salt), ERC7579ExecutorUnauthorizedSchedule()); _schedule(account, mode, executionCalldata, salt); } @@ -295,7 +295,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * scheduled the operation. See {_cancel}. */ function cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - require(canCancel(account, mode, executionCalldata, salt), ERC7579UnauthorizedCancellation()); + require(canCancel(account, mode, executionCalldata, salt), ERC7579ExecutorUnauthorizedCancellation()); _cancel(account, mode, executionCalldata, salt); } @@ -305,8 +305,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * 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, but module implementations may want to override this behavior to clear - * scheduled operations during uninstallation for their specific use cases. + * 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 diff --git a/contracts/mocks/account/modules/ERC7579MultisigMock.sol b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol similarity index 100% rename from contracts/mocks/account/modules/ERC7579MultisigMock.sol rename to contracts/mocks/account/modules/ERC7579MultisigMocks.sol diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index a9f49f73..f1ec1765 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -155,10 +155,10 @@ describe('ERC7579DelayedExecutor', function () { ).to.eventually.deep.equal([now, now + this.delay, now + this.delay + this.expiration]); }); - it('reverts with ERC7579UnauthorizedSchedule if called by other account', async function () { + it('reverts with ERC7579ExecutorUnauthorizedSchedule if called by other account', async function () { await expect( this.mock.schedule(this.mockAccount.address, this.mode, this.calldata, salt), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedSchedule'); + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedSchedule'); }); }); @@ -231,10 +231,10 @@ describe('ERC7579DelayedExecutor', function () { ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't cancel twice }); - it('reverts with ERC7579UnauthorizedCancellation if called by other account', async function () { + it('reverts with ERC7579ExecutorUnauthorizedCancellation if called by other account', async function () { await expect( this.mock.cancel(this.mockAccount.address, this.mode, this.calldata, salt), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedCancellation'); + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedCancellation'); }); }); }); From fdf0a508caf91d5bf8af7bb8e73ccda330f99f30 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 13 May 2025 18:19:05 -0600 Subject: [PATCH 73/90] Reverse order of errors in schedule, execute and cancel --- contracts/account/modules/ERC7579DelayedExecutor.sol | 10 ++++++---- contracts/account/modules/ERC7579Executor.sol | 6 ++++-- test/account/modules/ERC7579DelayedExecutor.test.js | 12 ++++++------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 43b029e5..5baffedc 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -286,8 +286,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * See {canSchedule} for authorization checks. */ function schedule(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - require(canSchedule(account, mode, executionCalldata, salt), ERC7579ExecutorUnauthorizedSchedule()); - _schedule(account, mode, executionCalldata, salt); + bool allowed = canSchedule(account, mode, executionCalldata, salt); + _schedule(account, mode, executionCalldata, salt); // Prioritize errors thrown in _schedule + require(allowed, ERC7579ExecutorUnauthorizedSchedule()); } /** @@ -295,8 +296,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * scheduled the operation. See {_cancel}. */ function cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - require(canCancel(account, mode, executionCalldata, salt), ERC7579ExecutorUnauthorizedCancellation()); - _cancel(account, mode, executionCalldata, salt); + bool allowed = canCancel(account, mode, executionCalldata, salt); + _cancel(account, mode, executionCalldata, salt); // Prioritize errors thrown in _cancel + require(allowed, ERC7579ExecutorUnauthorizedCancellation()); } /** diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol index 210506f7..9ed39310 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -68,8 +68,10 @@ abstract contract ERC7579Executor is IERC7579Module { bytes calldata executionCalldata, bytes32 salt ) public virtual returns (bytes[] memory returnData) { - require(canExecute(account, mode, executionCalldata, salt), ERC7579UnauthorizedExecution()); - return _execute(account, mode, executionCalldata, salt); + bool allowed = canExecute(account, mode, executionCalldata, salt); + returnData = _execute(account, mode, executionCalldata, salt); // Prioritize errors thrown in _execute + require(allowed, ERC7579UnauthorizedExecution()); + return returnData; } /** diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index f1ec1765..d9fd9eae 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -168,13 +168,13 @@ describe('ERC7579DelayedExecutor', function () { await this.mock.$_schedule(this.mockAccount.address, this.mode, this.calldata, salt); }); - it('reverts with ERC7579UnauthorizedExecution before delay passes with any caller', async function () { + it('reverts with ERC7579ExecutorUnexpectedOperationState before delay passes with any caller', async function () { await expect( this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); }); - it('reverts with ERC7579UnauthorizedExecution before delay passes with the account as caller', async function () { + it('reverts with ERC7579ExecutorUnexpectedOperationState before delay passes with the account as caller', async function () { await expect( this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Allowed, not ready @@ -187,7 +187,7 @@ describe('ERC7579DelayedExecutor', function () { .withArgs(...this.args); await expect( this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); // Can't execute twice + ).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 () { @@ -200,11 +200,11 @@ describe('ERC7579DelayedExecutor', function () { ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't execute twice }); - it('reverts if the operation was expired with any caller', async function () { + 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, this.mode, this.calldata, salt), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); }); it('reverts if the operation was expired with the account as caller', async function () { From 35324f2337cf188e1a9d56281511ac9549281dae Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 13 May 2025 18:37:58 -0600 Subject: [PATCH 74/90] Improve docs --- .../modules/ERC7579DelayedExecutor.sol | 46 ++++++++++++------- contracts/account/modules/ERC7579Executor.sol | 2 +- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 5baffedc..39bc120c 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -10,18 +10,28 @@ 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. * - * Once scheduled (see {schedule}), operations can only be executed after their specified delay - * period has elapsed (indicated during {onInstall}), creating a security window where suspicious - * operations can be monitored and potentially canceled (see {cancel}) before execution (see {execute}). + * === Operation Lifecycle * - * Accounts can customize their delay periods with {setDelay}. Changes take effect after a transition - * period to prevent immediate security downgrades. Operations have an expiration mechanism that - * prevents them from being executed after a certain time has passed. + * 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. * - * IMPORTANT: This module assumes the {AccountERC7579} is the ultimate authority and does not restrict - * module uninstallation. An account can bypass the time-delay security by simply uninstalling - * the module. Consider adding safeguards in your Account implementation if uninstallation - * protection is required for your security model. + * 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. */ abstract contract ERC7579DelayedExecutor is ERC7579Executor { using Time for *; @@ -112,7 +122,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return OperationState.Ready; } - /// @dev See {ERC7579Executor-canExecute}. Allows anyone to execute an operation if it's {OperationState-Ready}. + /// @dev See {ERC7579Executor-canExecute}. Allows anyone to execute an operation if it's `Ready`. function canExecute( address account, Mode mode, @@ -131,7 +141,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Example extension: * - * ``` + * ```solidity * function canCancel( * address account, * Mode mode, @@ -159,7 +169,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Example extension: * - * ``` + * ```solidity * function canSchedule( * address account, * Mode mode, @@ -348,7 +358,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Requirements: * - * * The operation must be {OperationState-Unknown}. + * * The operation must be `Unknown`. * * Emits an {ERC7579ExecutorOperationScheduled} event. */ @@ -377,9 +387,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Requirements: * - * * The operation must be {OperationState-Ready}. + * * The operation must be `Ready`. * - * NOTE: Anyone can trigger execution once the operation is {OperationState-Ready}. See {canExecute}. + * NOTE: Anyone can trigger execution once the operation is `Ready`. See {canExecute}. */ function _execute( address account, @@ -400,7 +410,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Requirements: * - * * The operation must be {OperationState-Scheduled} or {OperationState-Ready}. + * * The operation must be `Scheduled` or `Ready`. * * Canceled operations can't be rescheduled. Emits an {ERC7579ExecutorOperationCanceled} event. */ @@ -433,6 +443,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * @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 @@ -440,6 +451,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * ^--- 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 index 9ed39310..846406e4 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -36,7 +36,7 @@ abstract contract ERC7579Executor is IERC7579Module { * * Example extension: * - * ``` + * ```solidity * function canExecute( * address account, * Mode mode, From 0065af11f956d8cc21c81bf315e0ce2c69e88679 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Tue, 13 May 2025 19:16:29 -0600 Subject: [PATCH 75/90] Fix title levels in ERC7579DelayedExecutor --- contracts/account/modules/ERC7579DelayedExecutor.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 39bc120c..bcd72b49 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -10,7 +10,7 @@ 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 + * ==== 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 @@ -25,7 +25,7 @@ import {ERC7579Executor} from "./ERC7579Executor.sol"; * 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 + * ==== 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 From 9ea3743ce0671baa8464dcb7f575ea0cd0c4b8fc Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 14 May 2025 22:05:49 -0600 Subject: [PATCH 76/90] Change _checkMultisignature for _validateMultisignature --- contracts/account/modules/ERC7579Multisig.sol | 24 +++++++------- test/account/modules/ERC7579Multisig.test.js | 24 ++++++-------- .../modules/ERC7579MultisigWeighted.test.js | 31 +++++++++---------- 3 files changed, 35 insertions(+), 44 deletions(-) diff --git a/contracts/account/modules/ERC7579Multisig.sol b/contracts/account/modules/ERC7579Multisig.sol index 2e264058..971ba742 100644 --- a/contracts/account/modules/ERC7579Multisig.sol +++ b/contracts/account/modules/ERC7579Multisig.sol @@ -11,7 +11,7 @@ import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol * validation. * * This module provides a base implementation for multisignature validation that can be - * attached to any function through the {_checkMultiSignature} internal function. The signers + * 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`. * @@ -25,7 +25,7 @@ import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol * bytes32 salt, * bytes calldata signature * ) public virtual { - * _checkMultiSignature(account, hash, signature); + * require(_validateMultisignature(account, hash, signature)); * // ... rest of execute logic * } * ``` @@ -62,9 +62,6 @@ abstract contract ERC7579Multisig is IERC7579Module { /// @dev The `threshold` is unreachable given the number of `signers`. error ERC7579MultisigUnreachableThreshold(uint256 signers, uint256 threshold); - /// @dev The signatures are invalid. - error ERC7579MultisigInvalidSignatures(); - mapping(address account => EnumerableSetExtended.BytesSet) private _signersSetByAccount; mapping(address account => uint256) private _thresholdByAccount; @@ -172,9 +169,8 @@ abstract contract ERC7579Multisig is IERC7579Module { } /** - * @dev Checks whether the number of valid signatures meets or exceeds the - * threshold set for the target account. Reverts with {ERC7579MultisigInvalidSignatures} - * if the multisignature is not valid. + * @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)` @@ -182,13 +178,15 @@ abstract contract ERC7579Multisig is IERC7579Module { * Where `signingSigners` are the authorized signers and signatures are their corresponding * signatures of the operation `hash`. */ - function _checkMultiSignature(address account, bytes32 hash, bytes calldata signature) internal view virtual { + 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[])); - require( + return _validateThreshold(account, signingSigners) && - _validateSignatures(account, hash, signingSigners, signatures), - ERC7579MultisigInvalidSignatures() - ); + _validateSignatures(account, hash, signingSigners, signatures); } /** diff --git a/test/account/modules/ERC7579Multisig.test.js b/test/account/modules/ERC7579Multisig.test.js index 33b02c23..7eea5893 100644 --- a/test/account/modules/ERC7579Multisig.test.js +++ b/test/account/modules/ERC7579Multisig.test.js @@ -216,10 +216,9 @@ describe('ERC7579Multisig', function () { const testMessage = 'test'; const messageHash = ethers.hashMessage(testMessage); const multiSignature = await this.multiSigner.signMessage(testMessage); - await this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature); // Should succeed with valid signatures meeting threshold - await expect(this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature)).to.not.be - .reverted; + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.true; }); it('rejects signatures not meeting threshold', async function () { @@ -235,9 +234,8 @@ describe('ERC7579Multisig', function () { const multiSignature = await multiSigner.signMessage(testMessage); // Should fail because threshold is 2 but only 1 signature provided - await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.false; }); it('validates valid signatures meeting threshold', async function () { @@ -250,8 +248,8 @@ describe('ERC7579Multisig', function () { const multiSignature = await multiSigner.signMessage(testMessage); // Should succeed with valid signature meeting threshold - await expect(this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature)).to.not.be - .reverted; + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.true; }); it('rejects signatures from unauthorized signers', async function () { @@ -264,9 +262,8 @@ describe('ERC7579Multisig', function () { const multiSignature = await multiSigner.signMessage(testMessage); // Should fail because signer is not authorized - await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.false; }); it('rejects invalid signatures from authorized signers', async function () { @@ -277,9 +274,8 @@ describe('ERC7579Multisig', function () { const multiSignature = await this.multiSigner.signMessage(differentMessage); // Should fail because signature is for a different hash - await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, multiSignature), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, multiSignature)).to + .eventually.be.false; }); }); }); diff --git a/test/account/modules/ERC7579MultisigWeighted.test.js b/test/account/modules/ERC7579MultisigWeighted.test.js index 535ffaf7..2d9804ab 100644 --- a/test/account/modules/ERC7579MultisigWeighted.test.js +++ b/test/account/modules/ERC7579MultisigWeighted.test.js @@ -289,21 +289,21 @@ describe('ERC7579MultisigWeighted', function () { const exactSigner = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); const exactSignature = await exactSigner.signMessage(testMessage); - await expect(this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, exactSignature)).to.not.be - .reverted; + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, sufficientSignature)).to.not - .be.reverted; + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, minimumSignature)).to.not.be - .reverted; + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, minimumSignature)).to + .eventually.be.true; }); it('rejects signatures that collectively miss threshold', async function () { @@ -317,9 +317,8 @@ describe('ERC7579MultisigWeighted', function () { const insufficientSignature = await insufficientSigner.signMessage(testMessage); // Should fail because total weight (1) < threshold (4) - await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, insufficientSignature), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, insufficientSignature)).to + .eventually.be.false; }); it('considers weight changes when validating signatures', async function () { @@ -334,16 +333,15 @@ describe('ERC7579MultisigWeighted', function () { const insufficientSignature = await insufficientSigner.signMessage(testMessage); // First verify this combination is insufficient - await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, insufficientSignature), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + 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.$_checkMultiSignature(this.mockAccount.address, messageHash, insufficientSignature)).to.not - .be.reverted; + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, insufficientSignature)).to + .eventually.be.true; }); it('rejects invalid signatures regardless of weight', async function () { @@ -359,9 +357,8 @@ describe('ERC7579MultisigWeighted', function () { const invalidSignature = await invalidSigner.signMessage(differentMessage); // Should fail because signature is invalid for the hash - await expect( - this.mock.$_checkMultiSignature(this.mockAccount.address, messageHash, invalidSignature), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579MultisigInvalidSignatures'); + await expect(this.mock.$_validateMultisignature(this.mockAccount.address, messageHash, invalidSignature)).to + .eventually.be.false; }); }); }); From 03f3fe5806e8de6915ba63e65dafebfc6aa6a8b3 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 14 May 2025 22:08:18 -0600 Subject: [PATCH 77/90] Replace can* functions with internal _validate* in executors --- .../modules/ERC7579DelayedExecutor.sol | 46 +++++++++---------- contracts/account/modules/ERC7579Executor.sol | 32 ++++++------- lib/axelar-gmp-sdk-solidity | 1 + lib/zk-email-verify | 1 + 4 files changed, 37 insertions(+), 43 deletions(-) create mode 160000 lib/axelar-gmp-sdk-solidity create mode 160000 lib/zk-email-verify diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index bcd72b49..9423b9be 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -122,16 +122,14 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return OperationState.Ready; } - /// @dev See {ERC7579Executor-canExecute}. Allows anyone to execute an operation if it's `Ready`. - function canExecute( - address account, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt - ) public view virtual override returns (bool) { - return - state(account, mode, executionCalldata, salt) == OperationState.Ready || - super.canExecute(account, mode, executionCalldata, salt); + /// @inheritdoc ERC7579Executor + function _validateExecution( + address /* account */, + Mode /* mode */, + bytes calldata /* executionCalldata */, + bytes32 /* salt */ + ) internal view virtual override returns (bool) { + return true; // Anyone can execute, the state validation of the operation is enough } /** @@ -142,23 +140,23 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Example extension: * * ```solidity - * function canCancel( + * function _validateCancel( * address account, * Mode mode, * bytes calldata executionCalldata, * bytes32 salt - * ) public view virtual returns (bool) { + * ) internal view override returns (bool) { * bool isAuthorized = ...; // custom logic to check authorization - * return isAuthorized || super.canCancel(account, mode, executionCalldata, salt); + * return isAuthorized || super._validateCancel(account, mode, executionCalldata, salt); * } *``` */ - function canCancel( + function _validateCancel( address account, Mode /* mode */, bytes calldata /* executionCalldata */, bytes32 /* salt */ - ) public view virtual returns (bool) { + ) internal view virtual returns (bool) { return account == msg.sender; } @@ -170,23 +168,23 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Example extension: * * ```solidity - * function canSchedule( + * function _validateSchedule( * address account, * Mode mode, * bytes calldata executionCalldata, * bytes32 salt - * ) public view virtual returns (bool) { + * ) internal view override returns (bool) { * bool isAuthorized = ...; // custom logic to check authorization - * return isAuthorized || super.canSchedule(account, mode, executionCalldata, salt); + * return isAuthorized || super._validateSchedule(account, mode, executionCalldata, salt); * } *``` */ - function canSchedule( + function _validateSchedule( address account, Mode /* mode */, bytes calldata /* executionCalldata */, bytes32 /* salt */ - ) public view virtual returns (bool) { + ) internal view virtual returns (bool) { return account == msg.sender; } @@ -293,10 +291,10 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /** * @dev Schedules an operation to be executed after the account's delay period (see {getDelay}). * Operations are uniquely identified by the combination of `mode`, `executionCalldata`, and `salt`. - * See {canSchedule} for authorization checks. + * See {_validateSchedule} for authorization checks. */ function schedule(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - bool allowed = canSchedule(account, mode, executionCalldata, salt); + bool allowed = _validateSchedule(account, mode, executionCalldata, salt); _schedule(account, mode, executionCalldata, salt); // Prioritize errors thrown in _schedule require(allowed, ERC7579ExecutorUnauthorizedSchedule()); } @@ -306,7 +304,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * scheduled the operation. See {_cancel}. */ function cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - bool allowed = canCancel(account, mode, executionCalldata, salt); + bool allowed = _validateCancel(account, mode, executionCalldata, salt); _cancel(account, mode, executionCalldata, salt); // Prioritize errors thrown in _cancel require(allowed, ERC7579ExecutorUnauthorizedCancellation()); } @@ -388,8 +386,6 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Requirements: * * * The operation must be `Ready`. - * - * NOTE: Anyone can trigger execution once the operation is `Ready`. See {canExecute}. */ function _execute( address account, diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol index 846406e4..d95f2bca 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -9,9 +9,9 @@ import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol * for smart accounts. * * The module enables accounts to execute arbitrary operations, leveraging the execution - * capabilities defined in the ERC-7579 standard. By default, the executor is restricted to - * operations initiated by the account itself, but this can be customized in derived contracts - * by overriding the {canExecute} function. + * 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 @@ -21,8 +21,8 @@ abstract contract ERC7579Executor is IERC7579Module { /// @dev Emitted when an operation is executed. event ERC7579ExecutorOperationExecuted(address indexed account, Mode mode, bytes callData, bytes32 salt); - /// @dev Thrown when the executor is uninstalled. - error ERC7579UnauthorizedExecution(); + /// @dev Thrown when the execution is invalid. See {_validateExecution} for details. + error ERC7579InvalidExecution(); /// @inheritdoc IERC7579Module function isModuleType(uint256 moduleTypeId) public pure virtual returns (bool) { @@ -31,36 +31,32 @@ abstract contract ERC7579Executor is IERC7579Module { /** * @dev Check if the caller is authorized to execute operations. - * By default, this checks if the caller is the account itself. Derived contracts can - * override this to implement custom authorization logic. + * Derived contracts can implement this with custom authorization logic. * * Example extension: * * ```solidity - * function canExecute( + * function _validateExecution( * address account, * Mode mode, * bytes calldata executionCalldata, * bytes32 salt - * ) public view virtual returns (bool) { - * bool isAuthorized = ...; // custom logic to check authorization - * return isAuthorized || super.canExecute(account, mode, executionCalldata, salt); + * ) internal view virtual returns (bool) { + * return isAuthorized; // custom logic to check authorization * } *``` */ - function canExecute( + function _validateExecution( address account, Mode /* mode */, bytes calldata /* executionCalldata */, bytes32 /* salt */ - ) public view virtual returns (bool) { - return msg.sender == account; - } + ) internal view virtual returns (bool); /** * @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 - * {canExecute} for authorization checks. + * {_validateExecution} for authorization checks. */ function execute( address account, @@ -68,9 +64,9 @@ abstract contract ERC7579Executor is IERC7579Module { bytes calldata executionCalldata, bytes32 salt ) public virtual returns (bytes[] memory returnData) { - bool allowed = canExecute(account, mode, executionCalldata, salt); + bool allowed = _validateExecution(account, mode, executionCalldata, salt); returnData = _execute(account, mode, executionCalldata, salt); // Prioritize errors thrown in _execute - require(allowed, ERC7579UnauthorizedExecution()); + require(allowed, ERC7579InvalidExecution()); return returnData; } diff --git a/lib/axelar-gmp-sdk-solidity b/lib/axelar-gmp-sdk-solidity new file mode 160000 index 00000000..00682b6c --- /dev/null +++ b/lib/axelar-gmp-sdk-solidity @@ -0,0 +1 @@ +Subproject commit 00682b6c3db0cc922ec0c4ea3791852c93d7ae31 diff --git a/lib/zk-email-verify b/lib/zk-email-verify new file mode 160000 index 00000000..b193cf0c --- /dev/null +++ b/lib/zk-email-verify @@ -0,0 +1 @@ +Subproject commit b193cf0c760456b837b2bbcf7b2c72d5bb3f43c3 From 85047dfdc6205a229460d190738be6e03008ea5d Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 14 May 2025 22:10:07 -0600 Subject: [PATCH 78/90] Reorder --- .../modules/ERC7579DelayedExecutor.sol | 132 +++++++++--------- contracts/account/modules/ERC7579Executor.sol | 34 ++--- 2 files changed, 83 insertions(+), 83 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 9423b9be..0c9520db 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -122,72 +122,6 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return OperationState.Ready; } - /// @inheritdoc ERC7579Executor - function _validateExecution( - address /* account */, - Mode /* mode */, - bytes calldata /* executionCalldata */, - bytes32 /* salt */ - ) internal view virtual override returns (bool) { - return true; // 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, - * Mode mode, - * bytes calldata executionCalldata, - * bytes32 salt - * ) internal view override returns (bool) { - * bool isAuthorized = ...; // custom logic to check authorization - * return isAuthorized || super._validateCancel(account, mode, executionCalldata, salt); - * } - *``` - */ - function _validateCancel( - address account, - Mode /* mode */, - bytes calldata /* executionCalldata */, - bytes32 /* salt */ - ) 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, - * Mode mode, - * bytes calldata executionCalldata, - * bytes32 salt - * ) internal view override returns (bool) { - * bool isAuthorized = ...; // custom logic to check authorization - * return isAuthorized || super._validateSchedule(account, mode, executionCalldata, salt); - * } - *``` - */ - function _validateSchedule( - address account, - Mode /* mode */, - bytes calldata /* executionCalldata */, - bytes32 /* salt */ - ) internal view virtual returns (bool) { - return account == msg.sender; - } - /// @dev Minimum delay after which {setDelay} takes effect. function minSetback() public view virtual returns (uint32) { return 1 days; // Up to ~136 years @@ -329,6 +263,72 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { _setExpiration(msg.sender, 0); } + /// @inheritdoc ERC7579Executor + function _validateExecution( + address /* account */, + Mode /* mode */, + bytes calldata /* executionCalldata */, + bytes32 /* salt */ + ) internal view virtual override returns (bool) { + return true; // 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, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt + * ) internal view override returns (bool) { + * bool isAuthorized = ...; // custom logic to check authorization + * return isAuthorized || super._validateCancel(account, mode, executionCalldata, salt); + * } + *``` + */ + function _validateCancel( + address account, + Mode /* mode */, + bytes calldata /* executionCalldata */, + bytes32 /* salt */ + ) 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, + * Mode mode, + * bytes calldata executionCalldata, + * bytes32 salt + * ) internal view override returns (bool) { + * bool isAuthorized = ...; // custom logic to check authorization + * return isAuthorized || super._validateSchedule(account, mode, executionCalldata, salt); + * } + *``` + */ + function _validateSchedule( + address account, + Mode /* mode */, + bytes calldata /* executionCalldata */, + bytes32 /* salt */ + ) internal view virtual returns (bool) { + return account == msg.sender; + } + /** * @dev Internal implementation for setting an account's delay. See {getDelay}. * diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol index d95f2bca..5c578eed 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -29,6 +29,23 @@ abstract contract ERC7579Executor is IERC7579Module { 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, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) public virtual returns (bytes[] memory returnData) { + bool allowed = _validateExecution(account, mode, executionCalldata, salt); + returnData = _execute(account, mode, executionCalldata, salt); // 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. @@ -53,23 +70,6 @@ abstract contract ERC7579Executor is IERC7579Module { bytes32 /* salt */ ) internal view virtual returns (bool); - /** - * @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, - Mode mode, - bytes calldata executionCalldata, - bytes32 salt - ) public virtual returns (bytes[] memory returnData) { - bool allowed = _validateExecution(account, mode, executionCalldata, salt); - returnData = _execute(account, mode, executionCalldata, salt); // Prioritize errors thrown in _execute - require(allowed, ERC7579InvalidExecution()); - return returnData; - } - /** * @dev Internal version of {execute}. Emits {ERC7579ExecutorOperationExecuted} event. * From 5557f700bd198883f6ca42f8309e86234bdfb226 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 12:42:01 -0600 Subject: [PATCH 79/90] Fix tests --- .../modules/ERC7579DelayedExecutor.sol | 35 ++++++-- contracts/account/modules/ERC7579Executor.sol | 15 ++-- .../account/modules/ERC7579MultisigMocks.sol | 87 ++++++++++++++++++- lib/axelar-gmp-sdk-solidity | 1 - lib/zk-email-verify | 1 - .../modules/ERC7579DelayedExecutor.test.js | 28 +++--- test/account/modules/ERC7579Executor.test.js | 12 +-- test/account/modules/ERC7579Multisig.test.js | 2 +- .../modules/ERC7579MultisigWeighted.test.js | 2 +- 9 files changed, 138 insertions(+), 45 deletions(-) delete mode 160000 lib/axelar-gmp-sdk-solidity delete mode 160000 lib/zk-email-verify diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 0c9520db..36a12c08 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -227,8 +227,14 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Operations are uniquely identified by the combination of `mode`, `executionCalldata`, and `salt`. * See {_validateSchedule} for authorization checks. */ - function schedule(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - bool allowed = _validateSchedule(account, mode, executionCalldata, salt); + function schedule( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt, + bytes calldata extraData + ) public virtual { + bool allowed = _validateSchedule(account, mode, executionCalldata, salt, extraData); _schedule(account, mode, executionCalldata, salt); // Prioritize errors thrown in _schedule require(allowed, ERC7579ExecutorUnauthorizedSchedule()); } @@ -237,8 +243,14 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * @dev Cancels a previously scheduled operation. Can only be called by the account that * scheduled the operation. See {_cancel}. */ - function cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) public virtual { - bool allowed = _validateCancel(account, mode, executionCalldata, salt); + function cancel( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt, + bytes calldata extraData + ) public virtual { + bool allowed = _validateCancel(account, mode, executionCalldata, salt, extraData); _cancel(account, mode, executionCalldata, salt); // Prioritize errors thrown in _cancel require(allowed, ERC7579ExecutorUnauthorizedCancellation()); } @@ -268,7 +280,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { address /* account */, Mode /* mode */, bytes calldata /* executionCalldata */, - bytes32 /* salt */ + bytes32 /* salt */, + bytes calldata /* extraData */ ) internal view virtual override returns (bool) { return true; // Anyone can execute, the state validation of the operation is enough } @@ -285,7 +298,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * address account, * Mode mode, * bytes calldata executionCalldata, - * bytes32 salt + * bytes32 salt, + * bytes32 extraData * ) internal view override returns (bool) { * bool isAuthorized = ...; // custom logic to check authorization * return isAuthorized || super._validateCancel(account, mode, executionCalldata, salt); @@ -296,7 +310,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { address account, Mode /* mode */, bytes calldata /* executionCalldata */, - bytes32 /* salt */ + bytes32 /* salt */, + bytes calldata /* extraData */ ) internal view virtual returns (bool) { return account == msg.sender; } @@ -313,7 +328,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * address account, * Mode mode, * bytes calldata executionCalldata, - * bytes32 salt + * bytes32 salt, + * bytes32 extraData * ) internal view override returns (bool) { * bool isAuthorized = ...; // custom logic to check authorization * return isAuthorized || super._validateSchedule(account, mode, executionCalldata, salt); @@ -324,7 +340,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { address account, Mode /* mode */, bytes calldata /* executionCalldata */, - bytes32 /* salt */ + bytes32 /* salt */, + bytes calldata /* extraData */ ) internal view virtual returns (bool) { return account == msg.sender; } diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol index 5c578eed..88f97482 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -38,9 +38,10 @@ abstract contract ERC7579Executor is IERC7579Module { address account, Mode mode, bytes calldata executionCalldata, - bytes32 salt + bytes32 salt, + bytes calldata extraData ) public virtual returns (bytes[] memory returnData) { - bool allowed = _validateExecution(account, mode, executionCalldata, salt); + bool allowed = _validateExecution(account, mode, executionCalldata, salt, extraData); returnData = _execute(account, mode, executionCalldata, salt); // Prioritize errors thrown in _execute require(allowed, ERC7579InvalidExecution()); return returnData; @@ -57,7 +58,8 @@ abstract contract ERC7579Executor is IERC7579Module { * address account, * Mode mode, * bytes calldata executionCalldata, - * bytes32 salt + * bytes32 salt, + * bytes calldata extraData * ) internal view virtual returns (bool) { * return isAuthorized; // custom logic to check authorization * } @@ -65,9 +67,10 @@ abstract contract ERC7579Executor is IERC7579Module { */ function _validateExecution( address account, - Mode /* mode */, - bytes calldata /* executionCalldata */, - bytes32 /* salt */ + Mode mode, + bytes calldata executionCalldata, + bytes32 salt, + bytes calldata extraData // additional data for custom validation ) internal view virtual returns (bool); /** diff --git a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol index e604330a..a15fb606 100644 --- a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol +++ b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol @@ -2,12 +2,93 @@ 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 ERC7579Executor, ERC7579Multisig {} -abstract contract ERC7579MultisigWeightedExecutorMock is ERC7579Executor, ERC7579MultisigWeighted {} -abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ERC7579MultisigConfirmation {} +abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC7579Multisig { + bytes32 private constant EXECUTE_OPERATION = + keccak256("ExecuteOperation(address account,bytes32 mode,bytes executionCalldata,bytes32 salt)"); + + function _validateExecution( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt, + bytes calldata extraData + ) internal view override returns (bool) { + // We're missing the `signature` here + return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); + } + + function _getExecuteTypeHash( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal view returns (bytes32) { + return + _hashTypedDataV4( + keccak256(abi.encode(EXECUTE_OPERATION, account, Mode.unwrap(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)"); + + function _validateExecution( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt, + bytes calldata extraData + ) internal view override returns (bool) { + // We're missing the `signature` here + return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); + } + + function _getExecuteTypeHash( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal view returns (bytes32) { + return + _hashTypedDataV4( + keccak256(abi.encode(EXECUTE_OPERATION, account, Mode.unwrap(mode), executionCalldata, salt)) + ); + } +} +abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ERC7579MultisigConfirmation { + bytes32 private constant EXECUTE_OPERATION = + keccak256("ExecuteOperation(address account,bytes32 mode,bytes executionCalldata,bytes32 salt)"); + + function _validateExecution( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt, + bytes calldata extraData + ) internal view override returns (bool) { + // We're missing the `signature` here + return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); + } + + function _getExecuteTypeHash( + address account, + Mode mode, + bytes calldata executionCalldata, + bytes32 salt + ) internal view returns (bytes32) { + return + _hashTypedDataV4( + keccak256(abi.encode(EXECUTE_OPERATION, account, Mode.unwrap(mode), executionCalldata, salt)) + ); + } +} diff --git a/lib/axelar-gmp-sdk-solidity b/lib/axelar-gmp-sdk-solidity deleted file mode 160000 index 00682b6c..00000000 --- a/lib/axelar-gmp-sdk-solidity +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 00682b6c3db0cc922ec0c4ea3791852c93d7ae31 diff --git a/lib/zk-email-verify b/lib/zk-email-verify deleted file mode 160000 index b193cf0c..00000000 --- a/lib/zk-email-verify +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b193cf0c760456b837b2bbcf7b2c72d5bb3f43c3 diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index d9fd9eae..05d96ccc 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -142,13 +142,13 @@ describe('ERC7579DelayedExecutor', function () { it('schedules an operation if called by the account', async function () { const id = this.mock.hashOperation(this.mockAccount.address, this.mode, this.calldata, salt); - const tx = await this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt); + const tx = await this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt, '0x'); const now = await time.latest(); await expect(tx) .to.emit(this.mock, 'ERC7579ExecutorOperationScheduled') .withArgs(this.mockAccount.address, id, this.mode, this.calldata, salt, now); await expect( - this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt), + this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Can't schedule twice await expect( this.mock.getSchedule(this.mockAccount.address, this.mode, this.calldata, salt), @@ -157,7 +157,7 @@ describe('ERC7579DelayedExecutor', function () { it('reverts with ERC7579ExecutorUnauthorizedSchedule if called by other account', async function () { await expect( - this.mock.schedule(this.mockAccount.address, this.mode, this.calldata, salt), + this.mock.schedule(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedSchedule'); }); }); @@ -170,47 +170,47 @@ describe('ERC7579DelayedExecutor', function () { it('reverts with ERC7579ExecutorUnexpectedOperationState before delay passes with any caller', async function () { await expect( - this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt), + this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).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, this.mode, this.calldata, salt), + this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).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, this.mode, this.calldata, salt)) + await expect(this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x')) .to.emit(this.target, 'MockFunctionCalledWithArgs') .withArgs(...this.args); await expect( - this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt), + this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).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, this.mode, this.calldata, salt)) + await expect(this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x')) .to.emit(this.target, 'MockFunctionCalledWithArgs') .withArgs(...this.args); await expect( - this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt), + this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).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, this.mode, this.calldata, salt), + this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).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, this.mode, this.calldata, salt), + this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Allowed, expired }); }); @@ -223,17 +223,17 @@ describe('ERC7579DelayedExecutor', function () { it('cancels an operation if called by the account', async function () { const id = this.mock.hashOperation(this.mockAccount.address, this.mode, this.calldata, salt); - await expect(this.mockFromAccount.cancel(this.mockAccount.address, this.mode, this.calldata, salt)) + await expect(this.mockFromAccount.cancel(this.mockAccount.address, this.mode, this.calldata, salt, '0x')) .to.emit(this.mock, 'ERC7579ExecutorOperationCanceled') .withArgs(this.mockAccount.address, id); await expect( - this.mockFromAccount.cancel(this.mockAccount.address, this.mode, this.calldata, salt), + this.mockFromAccount.cancel(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).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, this.mode, this.calldata, salt), + this.mock.cancel(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedCancellation'); }); }); diff --git a/test/account/modules/ERC7579Executor.test.js b/test/account/modules/ERC7579Executor.test.js index f938d403..46e79e8b 100644 --- a/test/account/modules/ERC7579Executor.test.js +++ b/test/account/modules/ERC7579Executor.test.js @@ -15,7 +15,7 @@ const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); async function fixture() { // Deploy ERC-7579 validator module - const mock = await ethers.deployContract('$ERC7579MultisigExecutorMock'); + const mock = await ethers.deployContract('$ERC7579MultisigExecutorMock', ['MultisigExecutor', '1']); const target = await ethers.deployContract('CallReceiverMockExtended'); // ERC-4337 env @@ -61,18 +61,12 @@ describe('ERC7579Executor', function () { }); describe('execute', function () { - it('succeeds if called by the account', async function () { - await expect(this.mockFromAccount.execute(this.mockAccount.address, this.mode, this.calldata, ethers.ZeroHash)) + it('succeeds', async function () { + await expect(this.mockFromAccount.$_execute(this.mockAccount.address, this.mode, this.calldata, ethers.ZeroHash)) .to.emit(this.mock, 'ERC7579ExecutorOperationExecuted') .to.emit(this.target, 'MockFunctionCalledWithArgs') .withArgs(...this.args); }); - - it('reverts with ERC7579UnauthorizedExecution if called by an authorized sender', async function () { - await expect( - this.mock.execute(this.mockAccount.address, this.mode, this.calldata, ethers.ZeroHash), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579UnauthorizedExecution'); - }); }); shouldBehaveLikeERC7579Module(); diff --git a/test/account/modules/ERC7579Multisig.test.js b/test/account/modules/ERC7579Multisig.test.js index 7eea5893..177052b1 100644 --- a/test/account/modules/ERC7579Multisig.test.js +++ b/test/account/modules/ERC7579Multisig.test.js @@ -22,7 +22,7 @@ const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer async function fixture() { // Deploy ERC-7579 multisig module - const mock = await ethers.deployContract('$ERC7579MultisigExecutorMock'); + const mock = await ethers.deployContract('$ERC7579MultisigExecutorMock', ['MultisigExecutor', '1']); const target = await ethers.deployContract('CallReceiverMockExtended'); // ERC-4337 env diff --git a/test/account/modules/ERC7579MultisigWeighted.test.js b/test/account/modules/ERC7579MultisigWeighted.test.js index 2d9804ab..959e9bc0 100644 --- a/test/account/modules/ERC7579MultisigWeighted.test.js +++ b/test/account/modules/ERC7579MultisigWeighted.test.js @@ -22,7 +22,7 @@ const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer async function fixture() { // Deploy ERC-7579 multisig weighted module - const mock = await ethers.deployContract('$ERC7579MultisigWeightedExecutorMock'); + const mock = await ethers.deployContract('$ERC7579MultisigWeightedExecutorMock', ['MultisigWeightedExecutor', '1']); const target = await ethers.deployContract('CallReceiverMockExtended'); // ERC-4337 env From ab3c0ee818dc6445635e3159ddb06f12e368e104 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 16:47:56 -0600 Subject: [PATCH 80/90] Simplify types --- .../modules/ERC7579DelayedExecutor.sol | 29 +++++++++-------- contracts/account/modules/ERC7579Executor.sol | 15 +++++---- .../account/modules/ERC7579MultisigMocks.sol | 31 ++++++------------- 3 files changed, 31 insertions(+), 44 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 36a12c08..85a33634 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.27; import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; -import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; import {IERC7579ModuleConfig, MODULE_TYPE_EXECUTOR} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; import {ERC7579Executor} from "./ERC7579Executor.sol"; @@ -65,7 +64,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { event ERC7579ExecutorOperationScheduled( address indexed account, bytes32 indexed operationId, - Mode mode, + bytes32 mode, bytes executionCalldata, bytes32 salt, uint48 schedule @@ -104,7 +103,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Current state of an operation. function state( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) public view returns (OperationState) { @@ -142,7 +141,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Schedule for an operation. Returns default values if not set (i.e. `uint48(0)`, `uint48(0)`, `uint48(0)`). function getSchedule( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt) { @@ -161,7 +160,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Returns the operation id. function hashOperation( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) public view virtual returns (bytes32) { @@ -229,7 +228,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function schedule( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt, bytes calldata extraData @@ -245,7 +244,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function cancel( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt, bytes calldata extraData @@ -278,7 +277,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @inheritdoc ERC7579Executor function _validateExecution( address /* account */, - Mode /* mode */, + bytes32 /* mode */, bytes calldata /* executionCalldata */, bytes32 /* salt */, bytes calldata /* extraData */ @@ -296,7 +295,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * ```solidity * function _validateCancel( * address account, - * Mode mode, + * bytes32 mode, * bytes calldata executionCalldata, * bytes32 salt, * bytes32 extraData @@ -308,7 +307,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _validateCancel( address account, - Mode /* mode */, + bytes32 /* mode */, bytes calldata /* executionCalldata */, bytes32 /* salt */, bytes calldata /* extraData */ @@ -326,7 +325,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * ```solidity * function _validateSchedule( * address account, - * Mode mode, + * bytes32 mode, * bytes calldata executionCalldata, * bytes32 salt, * bytes32 extraData @@ -338,7 +337,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _validateSchedule( address account, - Mode /* mode */, + bytes32 /* mode */, bytes calldata /* executionCalldata */, bytes32 /* salt */, bytes calldata /* extraData */ @@ -379,7 +378,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _schedule( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) internal virtual returns (bytes32 operationId, Schedule memory schedule_) { @@ -406,7 +405,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _execute( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) internal virtual override returns (bytes[] memory returnData) { @@ -427,7 +426,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Canceled operations can't be rescheduled. Emits an {ERC7579ExecutorOperationCanceled} event. */ - function _cancel(address account, Mode mode, bytes calldata executionCalldata, bytes32 salt) internal virtual { + function _cancel(address account, bytes32 mode, bytes calldata executionCalldata, bytes32 salt) internal virtual { bytes32 id = hashOperation(account, mode, executionCalldata, salt); bytes32 allowedStates = _encodeStateBitmap(OperationState.Scheduled) | _encodeStateBitmap(OperationState.Ready); _validateStateBitmap(id, allowedStates); diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol index 88f97482..20967209 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.27; import {IERC7579Module, MODULE_TYPE_EXECUTOR, IERC7579Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; -import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; /** * @dev Basic implementation for ERC-7579 executor modules that provides execution functionality @@ -19,7 +18,7 @@ import {Mode} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol */ abstract contract ERC7579Executor is IERC7579Module { /// @dev Emitted when an operation is executed. - event ERC7579ExecutorOperationExecuted(address indexed account, Mode mode, bytes callData, bytes32 salt); + event ERC7579ExecutorOperationExecuted(address indexed account, bytes32 mode, bytes callData, bytes32 salt); /// @dev Thrown when the execution is invalid. See {_validateExecution} for details. error ERC7579InvalidExecution(); @@ -36,7 +35,7 @@ abstract contract ERC7579Executor is IERC7579Module { */ function execute( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt, bytes calldata extraData @@ -56,18 +55,18 @@ abstract contract ERC7579Executor is IERC7579Module { * ```solidity * function _validateExecution( * address account, - * Mode mode, + * bytes32 mode, * bytes calldata executionCalldata, * bytes32 salt, * bytes calldata extraData - * ) internal view virtual returns (bool) { + * ) internal view override returns (bool) { * return isAuthorized; // custom logic to check authorization * } *``` */ function _validateExecution( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt, bytes calldata extraData // additional data for custom validation @@ -82,11 +81,11 @@ abstract contract ERC7579Executor is IERC7579Module { */ function _execute( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) internal virtual returns (bytes[] memory returnData) { emit ERC7579ExecutorOperationExecuted(account, mode, executionCalldata, salt); - return IERC7579Execution(account).executeFromExecutor(Mode.unwrap(mode), executionCalldata); + return IERC7579Execution(account).executeFromExecutor(mode, executionCalldata); } } diff --git a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol index a15fb606..cb7a8e34 100644 --- a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol +++ b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol @@ -16,25 +16,21 @@ abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC757 function _validateExecution( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt, bytes calldata extraData ) internal view override returns (bool) { - // We're missing the `signature` here return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); } function _getExecuteTypeHash( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) internal view returns (bytes32) { - return - _hashTypedDataV4( - keccak256(abi.encode(EXECUTE_OPERATION, account, Mode.unwrap(mode), executionCalldata, salt)) - ); + return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, mode, executionCalldata, salt))); } } @@ -44,51 +40,44 @@ abstract contract ERC7579MultisigWeightedExecutorMock is EIP712, ERC7579Executor function _validateExecution( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt, bytes calldata extraData ) internal view override returns (bool) { - // We're missing the `signature` here return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); } function _getExecuteTypeHash( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) internal view returns (bytes32) { - return - _hashTypedDataV4( - keccak256(abi.encode(EXECUTE_OPERATION, account, Mode.unwrap(mode), executionCalldata, salt)) - ); + 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)"); function _validateExecution( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt, bytes calldata extraData ) internal view override returns (bool) { - // We're missing the `signature` here return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); } function _getExecuteTypeHash( address account, - Mode mode, + bytes32 mode, bytes calldata executionCalldata, bytes32 salt ) internal view returns (bytes32) { - return - _hashTypedDataV4( - keccak256(abi.encode(EXECUTE_OPERATION, account, Mode.unwrap(mode), executionCalldata, salt)) - ); + return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, mode, executionCalldata, salt))); } } From c251f6912983c1ff62f866c3298bd0f573c7bd5b Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 17:25:01 -0600 Subject: [PATCH 81/90] Use data to pack extra information on execution --- .../modules/ERC7579DelayedExecutor.sol | 59 +++++++----------- contracts/account/modules/ERC7579Executor.sol | 29 +++++---- .../account/modules/ERC7579MultisigMocks.sol | 60 ++++++++++++++----- 3 files changed, 82 insertions(+), 66 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 85a33634..9fb80078 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -223,18 +223,12 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /** * @dev Schedules an operation to be executed after the account's delay period (see {getDelay}). - * Operations are uniquely identified by the combination of `mode`, `executionCalldata`, and `salt`. + * Operations are uniquely identified by the combination of `mode`, `data`, and `salt`. * See {_validateSchedule} for authorization checks. */ - function schedule( - address account, - bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata extraData - ) public virtual { - bool allowed = _validateSchedule(account, mode, executionCalldata, salt, extraData); - _schedule(account, mode, executionCalldata, salt); // Prioritize errors thrown in _schedule + function schedule(address account, bytes32 mode, bytes calldata data, bytes32 salt) public virtual { + bool allowed = _validateSchedule(account, mode, data, salt); + _schedule(account, mode, data, salt); // Prioritize errors thrown in _schedule require(allowed, ERC7579ExecutorUnauthorizedSchedule()); } @@ -242,15 +236,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * @dev Cancels a previously scheduled operation. Can only be called by the account that * scheduled the operation. See {_cancel}. */ - function cancel( - address account, - bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata extraData - ) public virtual { - bool allowed = _validateCancel(account, mode, executionCalldata, salt, extraData); - _cancel(account, mode, executionCalldata, salt); // Prioritize errors thrown in _cancel + function cancel(address account, bytes32 mode, bytes calldata data, bytes32 salt) public virtual { + bool allowed = _validateCancel(account, mode, data, salt); + _cancel(account, mode, data, salt); // Prioritize errors thrown in _cancel require(allowed, ERC7579ExecutorUnauthorizedCancellation()); } @@ -278,11 +266,10 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { function _validateExecution( address /* account */, bytes32 /* mode */, - bytes calldata /* executionCalldata */, - bytes32 /* salt */, - bytes calldata /* extraData */ - ) internal view virtual override returns (bool) { - return true; // Anyone can execute, the state validation of the operation is enough + bytes calldata data, + bytes32 /* salt */ + ) internal view virtual override returns (bool valid, bytes calldata executionCalldata) { + return (true, data); // Anyone can execute, the state validation of the operation is enough } /** @@ -296,21 +283,19 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * function _validateCancel( * address account, * bytes32 mode, - * bytes calldata executionCalldata, - * bytes32 salt, - * bytes32 extraData + * bytes calldata data, + * bytes32 salt * ) internal view override returns (bool) { * bool isAuthorized = ...; // custom logic to check authorization - * return isAuthorized || super._validateCancel(account, mode, executionCalldata, salt); + * return isAuthorized || super._validateCancel(account, mode, data, salt); * } *``` */ function _validateCancel( address account, bytes32 /* mode */, - bytes calldata /* executionCalldata */, - bytes32 /* salt */, - bytes calldata /* extraData */ + bytes calldata /* data */, + bytes32 /* salt */ ) internal view virtual returns (bool) { return account == msg.sender; } @@ -326,21 +311,19 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * function _validateSchedule( * address account, * bytes32 mode, - * bytes calldata executionCalldata, - * bytes32 salt, - * bytes32 extraData + * bytes calldata data, + * bytes32 salt * ) internal view override returns (bool) { * bool isAuthorized = ...; // custom logic to check authorization - * return isAuthorized || super._validateSchedule(account, mode, executionCalldata, salt); + * return isAuthorized || super._validateSchedule(account, mode, data, salt); * } *``` */ function _validateSchedule( address account, bytes32 /* mode */, - bytes calldata /* executionCalldata */, - bytes32 /* salt */, - bytes calldata /* extraData */ + bytes calldata /* data */, + bytes32 /* salt */ ) internal view virtual returns (bool) { return account == msg.sender; } diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol index 20967209..67caaee4 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -18,7 +18,12 @@ import {IERC7579Module, MODULE_TYPE_EXECUTOR, IERC7579Execution} from "@openzepp */ abstract contract ERC7579Executor is IERC7579Module { /// @dev Emitted when an operation is executed. - event ERC7579ExecutorOperationExecuted(address indexed account, bytes32 mode, bytes callData, bytes32 salt); + event ERC7579ExecutorOperationExecuted( + address indexed account, + bytes32 mode, + bytes executionCalldata, + bytes32 salt + ); /// @dev Thrown when the execution is invalid. See {_validateExecution} for details. error ERC7579InvalidExecution(); @@ -36,11 +41,10 @@ abstract contract ERC7579Executor is IERC7579Module { function execute( address account, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata extraData + bytes calldata data, + bytes32 salt ) public virtual returns (bytes[] memory returnData) { - bool allowed = _validateExecution(account, mode, executionCalldata, salt, extraData); + (bool allowed, bytes calldata executionCalldata) = _validateExecution(account, mode, data, salt); returnData = _execute(account, mode, executionCalldata, salt); // Prioritize errors thrown in _execute require(allowed, ERC7579InvalidExecution()); return returnData; @@ -56,10 +60,10 @@ abstract contract ERC7579Executor is IERC7579Module { * function _validateExecution( * address account, * bytes32 mode, - * bytes calldata executionCalldata, - * bytes32 salt, - * bytes calldata extraData - * ) internal view override returns (bool) { + * bytes calldata data, + * bytes32 salt + * ) internal view override returns (bool valid, bytes calldata executionCalldata) { + * /// ... * return isAuthorized; // custom logic to check authorization * } *``` @@ -67,10 +71,9 @@ abstract contract ERC7579Executor is IERC7579Module { function _validateExecution( address account, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata extraData // additional data for custom validation - ) internal view virtual returns (bool); + bytes calldata data, + bytes32 salt + ) internal view virtual returns (bool valid, bytes calldata executionCalldata); /** * @dev Internal version of {execute}. Emits {ERC7579ExecutorOperationExecuted} event. diff --git a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol index cb7a8e34..698935d2 100644 --- a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol +++ b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol @@ -14,14 +14,24 @@ abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC757 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 mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata extraData - ) internal view override returns (bool) { - return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); + bytes calldata data, + bytes32 salt + ) 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, mode, actualExecutionCalldata, salt), + signature + ), + actualExecutionCalldata + ); } function _getExecuteTypeHash( @@ -38,14 +48,24 @@ abstract contract ERC7579MultisigWeightedExecutorMock is EIP712, ERC7579Executor 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 mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata extraData - ) internal view override returns (bool) { - return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); + bytes calldata data, + bytes32 salt + ) 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, mode, actualExecutionCalldata, salt), + signature + ), + actualExecutionCalldata + ); } function _getExecuteTypeHash( @@ -62,14 +82,24 @@ abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ER 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 mode, - bytes calldata executionCalldata, - bytes32 salt, - bytes calldata extraData - ) internal view override returns (bool) { - return _validateMultisignature(account, _getExecuteTypeHash(account, mode, executionCalldata, salt), extraData); + bytes calldata data, + bytes32 salt + ) 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, mode, actualExecutionCalldata, salt), + signature + ), + actualExecutionCalldata + ); } function _getExecuteTypeHash( From ec0ae9e69881d6df5eb13af382d09079624bf079 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 17:37:58 -0600 Subject: [PATCH 82/90] Change order of arguments to leave bytes data at the end --- .../modules/ERC7579DelayedExecutor.sol | 62 +++++++++---------- contracts/account/modules/ERC7579Executor.sol | 26 ++++---- .../account/modules/ERC7579MultisigMocks.sol | 30 ++++----- .../modules/ERC7579DelayedExecutor.test.js | 40 ++++++------ test/account/modules/ERC7579Executor.test.js | 2 +- 5 files changed, 80 insertions(+), 80 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 9fb80078..d8d98efd 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -64,9 +64,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { event ERC7579ExecutorOperationScheduled( address indexed account, bytes32 indexed operationId, + bytes32 salt, bytes32 mode, bytes executionCalldata, - bytes32 salt, uint48 schedule ); @@ -103,11 +103,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Current state of an operation. function state( address account, + bytes32 salt, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + bytes calldata executionCalldata ) public view returns (OperationState) { - return state(hashOperation(account, mode, executionCalldata, salt)); + return state(hashOperation(account, salt, mode, executionCalldata)); } /// @dev Same as {state}, but for a specific operation id. @@ -141,11 +141,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @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, - bytes32 salt + bytes calldata executionCalldata ) public view virtual returns (uint48 scheduledAt, uint48 executableAt, uint48 expiresAt) { - return getSchedule(hashOperation(account, mode, executionCalldata, salt)); + return getSchedule(hashOperation(account, salt, mode, executionCalldata)); } /// @dev Same as {getSchedule} but with the operation id. @@ -160,11 +160,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @dev Returns the operation id. function hashOperation( address account, + bytes32 salt, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + bytes calldata executionCalldata ) public view virtual returns (bytes32) { - return keccak256(abi.encode(account, mode, executionCalldata, salt)); + return keccak256(abi.encode(account, salt, mode, executionCalldata)); } /// @dev Default delay for account operations. Set if not provided during {onInstall}. @@ -223,12 +223,12 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /** * @dev Schedules an operation to be executed after the account's delay period (see {getDelay}). - * Operations are uniquely identified by the combination of `mode`, `data`, and `salt`. + * Operations are uniquely identified by the combination of `salt`, `mode`, and `data`. * See {_validateSchedule} for authorization checks. */ - function schedule(address account, bytes32 mode, bytes calldata data, bytes32 salt) public virtual { - bool allowed = _validateSchedule(account, mode, data, salt); - _schedule(account, mode, data, salt); // Prioritize errors thrown in _schedule + function schedule(address account, bytes32 salt, bytes32 mode, bytes calldata data) public virtual { + bool allowed = _validateSchedule(account, salt, mode, data); + _schedule(account, salt, mode, data); // Prioritize errors thrown in _schedule require(allowed, ERC7579ExecutorUnauthorizedSchedule()); } @@ -236,8 +236,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * @dev Cancels a previously scheduled operation. Can only be called by the account that * scheduled the operation. See {_cancel}. */ - function cancel(address account, bytes32 mode, bytes calldata data, bytes32 salt) public virtual { - bool allowed = _validateCancel(account, mode, data, salt); + 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()); } @@ -265,9 +265,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @inheritdoc ERC7579Executor function _validateExecution( address /* account */, + bytes32 /* salt */, bytes32 /* mode */, - bytes calldata data, - bytes32 /* salt */ + 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 } @@ -293,9 +293,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _validateCancel( address account, + bytes32 /* salt */, bytes32 /* mode */, - bytes calldata /* data */, - bytes32 /* salt */ + bytes calldata /* data */ ) internal view virtual returns (bool) { return account == msg.sender; } @@ -321,9 +321,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _validateSchedule( address account, + bytes32 /* salt */, bytes32 /* mode */, - bytes calldata /* data */, - bytes32 /* salt */ + bytes calldata /* data */ ) internal view virtual returns (bool) { return account == msg.sender; } @@ -361,11 +361,11 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _schedule( address account, + bytes32 salt, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + bytes calldata executionCalldata ) internal virtual returns (bytes32 operationId, Schedule memory schedule_) { - bytes32 id = hashOperation(account, mode, executionCalldata, salt); + bytes32 id = hashOperation(account, salt, mode, executionCalldata); _validateStateBitmap(id, _encodeStateBitmap(OperationState.Unknown)); (uint32 executableAfter, , ) = getDelay(account); @@ -375,7 +375,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { _schedules[id].executableAfter = executableAfter; _schedules[id].expiresAfter = getExpiration(account); - emit ERC7579ExecutorOperationScheduled(account, id, mode, executionCalldata, salt, timepoint); + emit ERC7579ExecutorOperationScheduled(account, id, salt, mode, executionCalldata, timepoint); return (id, schedule_); } @@ -388,16 +388,16 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { */ function _execute( address account, + bytes32 salt, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + bytes calldata executionCalldata ) internal virtual override returns (bytes[] memory returnData) { - bytes32 id = hashOperation(account, mode, executionCalldata, salt); + bytes32 id = hashOperation(account, salt, mode, executionCalldata); _validateStateBitmap(id, _encodeStateBitmap(OperationState.Ready)); _schedules[id].executed = true; - return super._execute(account, mode, executionCalldata, salt); + return super._execute(account, salt, mode, executionCalldata); } /** @@ -410,7 +410,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * 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, mode, executionCalldata, salt); + bytes32 id = hashOperation(account, salt, mode, executionCalldata); bytes32 allowedStates = _encodeStateBitmap(OperationState.Scheduled) | _encodeStateBitmap(OperationState.Ready); _validateStateBitmap(id, allowedStates); diff --git a/contracts/account/modules/ERC7579Executor.sol b/contracts/account/modules/ERC7579Executor.sol index 67caaee4..955e0cea 100644 --- a/contracts/account/modules/ERC7579Executor.sol +++ b/contracts/account/modules/ERC7579Executor.sol @@ -20,9 +20,9 @@ abstract contract ERC7579Executor is IERC7579Module { /// @dev Emitted when an operation is executed. event ERC7579ExecutorOperationExecuted( address indexed account, + bytes32 salt, bytes32 mode, - bytes executionCalldata, - bytes32 salt + bytes executionCalldata ); /// @dev Thrown when the execution is invalid. See {_validateExecution} for details. @@ -40,12 +40,12 @@ abstract contract ERC7579Executor is IERC7579Module { */ function execute( address account, + bytes32 salt, bytes32 mode, - bytes calldata data, - bytes32 salt + bytes calldata data ) public virtual returns (bytes[] memory returnData) { - (bool allowed, bytes calldata executionCalldata) = _validateExecution(account, mode, data, salt); - returnData = _execute(account, mode, executionCalldata, salt); // Prioritize errors thrown in _execute + (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; } @@ -59,9 +59,9 @@ abstract contract ERC7579Executor is IERC7579Module { * ```solidity * function _validateExecution( * address account, + * bytes32 salt, * bytes32 mode, - * bytes calldata data, - * bytes32 salt + * bytes calldata data * ) internal view override returns (bool valid, bytes calldata executionCalldata) { * /// ... * return isAuthorized; // custom logic to check authorization @@ -70,9 +70,9 @@ abstract contract ERC7579Executor is IERC7579Module { */ function _validateExecution( address account, + bytes32 salt, bytes32 mode, - bytes calldata data, - bytes32 salt + bytes calldata data ) internal view virtual returns (bool valid, bytes calldata executionCalldata); /** @@ -85,10 +85,10 @@ abstract contract ERC7579Executor is IERC7579Module { function _execute( address account, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + bytes32 salt, + bytes calldata executionCalldata ) internal virtual returns (bytes[] memory returnData) { - emit ERC7579ExecutorOperationExecuted(account, mode, executionCalldata, salt); + emit ERC7579ExecutorOperationExecuted(account, salt, mode, executionCalldata); return IERC7579Execution(account).executeFromExecutor(mode, executionCalldata); } } diff --git a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol index 698935d2..08678bb2 100644 --- a/contracts/mocks/account/modules/ERC7579MultisigMocks.sol +++ b/contracts/mocks/account/modules/ERC7579MultisigMocks.sol @@ -17,9 +17,9 @@ abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC757 // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] function _validateExecution( address account, + bytes32 salt, bytes32 mode, - bytes calldata data, - bytes32 salt + 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 @@ -27,7 +27,7 @@ abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC757 return ( _validateMultisignature( account, - _getExecuteTypeHash(account, mode, actualExecutionCalldata, salt), + _getExecuteTypeHash(account, salt, mode, actualExecutionCalldata), signature ), actualExecutionCalldata @@ -36,9 +36,9 @@ abstract contract ERC7579MultisigExecutorMock is EIP712, ERC7579Executor, ERC757 function _getExecuteTypeHash( address account, + bytes32 salt, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + bytes calldata executionCalldata ) internal view returns (bytes32) { return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, mode, executionCalldata, salt))); } @@ -51,9 +51,9 @@ abstract contract ERC7579MultisigWeightedExecutorMock is EIP712, ERC7579Executor // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] function _validateExecution( address account, + bytes32 salt, bytes32 mode, - bytes calldata data, - bytes32 salt + 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 @@ -61,7 +61,7 @@ abstract contract ERC7579MultisigWeightedExecutorMock is EIP712, ERC7579Executor return ( _validateMultisignature( account, - _getExecuteTypeHash(account, mode, actualExecutionCalldata, salt), + _getExecuteTypeHash(account, salt, mode, actualExecutionCalldata), signature ), actualExecutionCalldata @@ -70,9 +70,9 @@ abstract contract ERC7579MultisigWeightedExecutorMock is EIP712, ERC7579Executor function _getExecuteTypeHash( address account, + bytes32 salt, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + bytes calldata executionCalldata ) internal view returns (bytes32) { return _hashTypedDataV4(keccak256(abi.encode(EXECUTE_OPERATION, account, mode, executionCalldata, salt))); } @@ -85,9 +85,9 @@ abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ER // Data encoding: [uint16(executionCalldataLength), executionCalldata, signature] function _validateExecution( address account, + bytes32 salt, bytes32 mode, - bytes calldata data, - bytes32 salt + 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 @@ -95,7 +95,7 @@ abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ER return ( _validateMultisignature( account, - _getExecuteTypeHash(account, mode, actualExecutionCalldata, salt), + _getExecuteTypeHash(account, salt, mode, actualExecutionCalldata), signature ), actualExecutionCalldata @@ -104,9 +104,9 @@ abstract contract ERC7579MultisigConfirmationExecutorMock is ERC7579Executor, ER function _getExecuteTypeHash( address account, + bytes32 salt, bytes32 mode, - bytes calldata executionCalldata, - bytes32 salt + 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 index 05d96ccc..a64763a8 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -141,23 +141,23 @@ describe('ERC7579DelayedExecutor', function () { }); it('schedules an operation if called by the account', async function () { - const id = this.mock.hashOperation(this.mockAccount.address, this.mode, this.calldata, salt); - const tx = await this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt, '0x'); + 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, this.mode, this.calldata, salt, now); + .withArgs(this.mockAccount.address, id, salt, this.mode, this.calldata, now); await expect( - this.mockFromAccount.schedule(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), + 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, this.mode, this.calldata, salt), + 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, this.mode, this.calldata, salt, '0x'), + this.mock.schedule(this.mockAccount.address, salt, this.mode, this.calldata), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedSchedule'); }); }); @@ -165,52 +165,52 @@ describe('ERC7579DelayedExecutor', function () { describe('execution', function () { beforeEach(async function () { await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); - await this.mock.$_schedule(this.mockAccount.address, this.mode, this.calldata, salt); + await this.mock.$_schedule(this.mockAccount.address, salt, this.mode, this.calldata); }); it('reverts with ERC7579ExecutorUnexpectedOperationState before delay passes with any caller', async function () { await expect( - this.mock.execute(this.mockAccount.address, this.mode, this.calldata, salt, '0x'), + 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, this.mode, this.calldata, salt, '0x'), + 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, this.mode, this.calldata, salt, '0x')) + 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, this.mode, this.calldata, salt, '0x'), + 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, this.mode, this.calldata, salt, '0x')) + 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, this.mode, this.calldata, salt, '0x'), + 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, this.mode, this.calldata, salt, '0x'), + 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, this.mode, this.calldata, salt, '0x'), + this.mockFromAccount.execute(this.mockAccount.address, salt, this.mode, this.calldata), ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnexpectedOperationState'); // Allowed, expired }); }); @@ -218,22 +218,22 @@ describe('ERC7579DelayedExecutor', function () { describe('cancelling', function () { beforeEach(async function () { await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); - await this.mock.$_schedule(this.mockAccount.address, this.mode, this.calldata, salt); + await this.mock.$_schedule(this.mockAccount.address, salt, this.mode, this.calldata); }); it('cancels an operation if called by the account', async function () { - const id = this.mock.hashOperation(this.mockAccount.address, this.mode, this.calldata, salt); - await expect(this.mockFromAccount.cancel(this.mockAccount.address, this.mode, this.calldata, salt, '0x')) + 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, this.mode, this.calldata, salt, '0x'), + 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, this.mode, this.calldata, salt, '0x'), + 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 index 46e79e8b..2e6b1caf 100644 --- a/test/account/modules/ERC7579Executor.test.js +++ b/test/account/modules/ERC7579Executor.test.js @@ -62,7 +62,7 @@ describe('ERC7579Executor', function () { describe('execute', function () { it('succeeds', async function () { - await expect(this.mockFromAccount.$_execute(this.mockAccount.address, this.mode, this.calldata, ethers.ZeroHash)) + 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); From 700558db5f4dfd0f55a30f4ecc7131bf30be55ac Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 18:07:15 -0600 Subject: [PATCH 83/90] Make schedule and cancel noops when not authorized --- .../modules/ERC7579DelayedExecutor.sol | 20 +++++++------------ .../modules/ERC7579DelayedExecutor.test.js | 20 +++++++++++++------ test/helpers/enums.js | 1 + 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index d8d98efd..020ba8d7 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -91,12 +91,6 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes32 allowedStates ); - /// @dev The operation is not authorized to be canceled. - error ERC7579ExecutorUnauthorizedCancellation(); - - /// @dev The operation is not authorized to be scheduled. - error ERC7579ExecutorUnauthorizedSchedule(); - mapping(address account => ExecutionConfig) private _config; mapping(bytes32 operationId => Schedule) private _schedules; @@ -227,9 +221,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * See {_validateSchedule} for authorization checks. */ function schedule(address account, bytes32 salt, bytes32 mode, bytes calldata data) public virtual { - bool allowed = _validateSchedule(account, salt, mode, data); - _schedule(account, salt, mode, data); // Prioritize errors thrown in _schedule - require(allowed, ERC7579ExecutorUnauthorizedSchedule()); + if (_validateSchedule(account, salt, mode, data)) { + _schedule(account, salt, mode, data); + } // else no-op } /** @@ -237,9 +231,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * 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()); + if (_validateCancel(account, salt, mode, data)) { + _cancel(account, mode, data, salt); + } // else no-op } /** @@ -325,7 +319,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes32 /* mode */, bytes calldata /* data */ ) internal view virtual returns (bool) { - return account == msg.sender; + return _config[account].installed && account == msg.sender; } /** diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index a64763a8..19653ea1 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -12,6 +12,7 @@ const { encodeSingle, } = require('@openzeppelin/contracts/test/helpers/erc7579'); const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); +const { OperationState, ProposalState } = require('../../helpers/enums'); async function fixture() { // Deploy ERC-7579 validator module @@ -155,10 +156,14 @@ describe('ERC7579DelayedExecutor', function () { ).to.eventually.deep.equal([now, now + this.delay, now + this.delay + this.expiration]); }); - it('reverts with ERC7579ExecutorUnauthorizedSchedule if called by other account', async function () { + it('no-ops if called by other account', async function () { + await this.mock.schedule(this.mockAccount.address, salt, this.mode, this.calldata); // not revert await expect( - this.mock.schedule(this.mockAccount.address, salt, this.mode, this.calldata), - ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedSchedule'); + this.mock.getSchedule(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.eventually.deep.equal([0n, 0n, 0n]); + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.equal( + OperationState.Unknown, + ); }); }); @@ -232,9 +237,12 @@ describe('ERC7579DelayedExecutor', function () { }); 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'); + const previousState = await this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata); + expect(previousState).to.not.eq(ProposalState.Canceled); + await this.mock.cancel(this.mockAccount.address, salt, this.mode, this.calldata); // not revert + await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.equal( + previousState, + ); }); }); }); diff --git a/test/helpers/enums.js b/test/helpers/enums.js index 37d9e576..d7bc35e9 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'), }; From 23c683b23f67838f02080ecef8b73a7fd6e9d957 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 18:52:38 -0600 Subject: [PATCH 84/90] Revert "Make schedule and cancel noops when not authorized" This reverts commit 700558db5f4dfd0f55a30f4ecc7131bf30be55ac. --- .../modules/ERC7579DelayedExecutor.sol | 20 ++++++++++++------- .../modules/ERC7579DelayedExecutor.test.js | 20 ++++++------------- test/helpers/enums.js | 1 - 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 020ba8d7..d8d98efd 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -91,6 +91,12 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes32 allowedStates ); + /// @dev The operation is not authorized to be canceled. + error ERC7579ExecutorUnauthorizedCancellation(); + + /// @dev The operation is not authorized to be scheduled. + error ERC7579ExecutorUnauthorizedSchedule(); + mapping(address account => ExecutionConfig) private _config; mapping(bytes32 operationId => Schedule) private _schedules; @@ -221,9 +227,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * See {_validateSchedule} for authorization checks. */ function schedule(address account, bytes32 salt, bytes32 mode, bytes calldata data) public virtual { - if (_validateSchedule(account, salt, mode, data)) { - _schedule(account, salt, mode, data); - } // else no-op + bool allowed = _validateSchedule(account, salt, mode, data); + _schedule(account, salt, mode, data); // Prioritize errors thrown in _schedule + require(allowed, ERC7579ExecutorUnauthorizedSchedule()); } /** @@ -231,9 +237,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * scheduled the operation. See {_cancel}. */ function cancel(address account, bytes32 salt, bytes32 mode, bytes calldata data) public virtual { - if (_validateCancel(account, salt, mode, data)) { - _cancel(account, mode, data, salt); - } // else no-op + bool allowed = _validateCancel(account, salt, mode, data); + _cancel(account, mode, data, salt); // Prioritize errors thrown in _cancel + require(allowed, ERC7579ExecutorUnauthorizedCancellation()); } /** @@ -319,7 +325,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { bytes32 /* mode */, bytes calldata /* data */ ) internal view virtual returns (bool) { - return _config[account].installed && account == msg.sender; + return account == msg.sender; } /** diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index 19653ea1..a64763a8 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -12,7 +12,6 @@ const { encodeSingle, } = require('@openzeppelin/contracts/test/helpers/erc7579'); const { shouldBehaveLikeERC7579Module } = require('./ERC7579Module.behavior'); -const { OperationState, ProposalState } = require('../../helpers/enums'); async function fixture() { // Deploy ERC-7579 validator module @@ -156,14 +155,10 @@ describe('ERC7579DelayedExecutor', function () { ).to.eventually.deep.equal([now, now + this.delay, now + this.delay + this.expiration]); }); - it('no-ops if called by other account', async function () { - await this.mock.schedule(this.mockAccount.address, salt, this.mode, this.calldata); // not revert + it('reverts with ERC7579ExecutorUnauthorizedSchedule if called by other account', async function () { await expect( - this.mock.getSchedule(this.mockAccount.address, salt, this.mode, this.calldata), - ).to.eventually.deep.equal([0n, 0n, 0n]); - await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.equal( - OperationState.Unknown, - ); + this.mock.schedule(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedSchedule'); }); }); @@ -237,12 +232,9 @@ describe('ERC7579DelayedExecutor', function () { }); it('reverts with ERC7579ExecutorUnauthorizedCancellation if called by other account', async function () { - const previousState = await this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata); - expect(previousState).to.not.eq(ProposalState.Canceled); - await this.mock.cancel(this.mockAccount.address, salt, this.mode, this.calldata); // not revert - await expect(this.mock.state(this.mockAccount.address, salt, this.mode, this.calldata)).to.eventually.equal( - previousState, - ); + await expect( + this.mock.cancel(this.mockAccount.address, salt, this.mode, this.calldata), + ).to.be.revertedWithCustomError(this.mock, 'ERC7579ExecutorUnauthorizedCancellation'); }); }); }); diff --git a/test/helpers/enums.js b/test/helpers/enums.js index d7bc35e9..37d9e576 100644 --- a/test/helpers/enums.js +++ b/test/helpers/enums.js @@ -11,5 +11,4 @@ module.exports = { 'EmailProof', ), Case: enums.EnumTyped('CHECKSUM', 'LOWERCASE', 'UPPERCASE', 'ANY'), - OperationState: enums.Enum('Unknown', 'Scheduled', 'Ready', 'Expired', 'Executed'), }; From 24dad81e9abc6fc66f964b777720b7b99771dc7c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 19:01:52 -0600 Subject: [PATCH 85/90] Revert on schedule if the module is not installed --- contracts/account/modules/ERC7579DelayedExecutor.sol | 4 ++++ test/account/modules/ERC7579DelayedExecutor.test.js | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index d8d98efd..7ad6f0e4 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -97,6 +97,9 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { /// @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; @@ -227,6 +230,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * 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); _schedule(account, salt, mode, data); // Prioritize errors thrown in _schedule require(allowed, ERC7579ExecutorUnauthorizedSchedule()); diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index a64763a8..29003d21 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -14,6 +14,8 @@ const { 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'); @@ -57,6 +59,7 @@ async function fixture() { mode, delay, expiration, + other, }; } @@ -160,6 +163,12 @@ describe('ERC7579DelayedExecutor', function () { 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 () { From 42a9fcba4f70663e29bda120626f25d56bab4dc3 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 19:48:30 -0600 Subject: [PATCH 86/90] Self-review --- .../modules/ERC7579DelayedExecutor.sol | 36 ++++++++++--------- .../modules/ERC7579DelayedExecutor.test.js | 10 ++++-- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 7ad6f0e4..7f715f72 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -31,6 +31,9 @@ import {ERC7579Executor} from "./ERC7579Executor.sol"; * 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 *; @@ -124,7 +127,10 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return OperationState.Ready; } - /// @dev Minimum delay after which {setDelay} takes effect. + /** + * @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 1 days; // Up to ~136 years } @@ -170,11 +176,6 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { return keccak256(abi.encode(account, salt, mode, executionCalldata)); } - /// @dev Default delay for account operations. Set if not provided during {onInstall}. - function defaultDelay() public view virtual returns (uint32) { - return 5 days; - } - /// @dev Default expiration for account operations. Set if not provided during {onInstall}. function defaultExpiration() public view virtual returns (uint32) { return 60 days; @@ -186,7 +187,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * 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 the minimum delay. + * Otherwise, the delay will be set to {minSetback} and {defaultExpiration} respectively. * * Behaves as a no-op if the module is already installed. * @@ -200,7 +201,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { _config[msg.sender].installed = true; (uint32 initialDelay, uint32 initialExpiration) = initData.length > 0 ? abi.decode(initData, (uint32, uint32)) - : (defaultDelay(), defaultExpiration()); + : (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); @@ -232,7 +233,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { 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); - _schedule(account, salt, mode, data); // Prioritize errors thrown in _schedule + (uint32 executableAfter, , ) = getDelay(account); + _scheduleAt(account, salt, mode, data, Time.timestamp(), executableAfter); require(allowed, ERC7579ExecutorUnauthorizedSchedule()); } @@ -355,7 +357,8 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { } /** - * @dev Internal version of {schedule} that takes an `account` address as an argument. + * @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: * @@ -363,23 +366,22 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * * Emits an {ERC7579ExecutorOperationScheduled} event. */ - function _schedule( + function _scheduleAt( address account, bytes32 salt, bytes32 mode, - bytes calldata executionCalldata + 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)); - (uint32 executableAfter, , ) = getDelay(account); - - uint48 timepoint = Time.timestamp(); _schedules[id].scheduledAt = timepoint; - _schedules[id].executableAfter = executableAfter; + _schedules[id].executableAfter = delay; _schedules[id].expiresAfter = getExpiration(account); - emit ERC7579ExecutorOperationScheduled(account, id, salt, mode, executionCalldata, timepoint); + emit ERC7579ExecutorOperationScheduled(account, id, salt, mode, executionCalldata, timepoint + delay); return (id, schedule_); } diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index 29003d21..4da4ae38 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -149,7 +149,7 @@ describe('ERC7579DelayedExecutor', function () { const now = await time.latest(); await expect(tx) .to.emit(this.mock, 'ERC7579ExecutorOperationScheduled') - .withArgs(this.mockAccount.address, id, salt, this.mode, this.calldata, now); + .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 @@ -174,7 +174,9 @@ describe('ERC7579DelayedExecutor', function () { describe('execution', function () { beforeEach(async function () { await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); - await this.mock.$_schedule(this.mockAccount.address, salt, this.mode, this.calldata); + 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 () { @@ -227,7 +229,9 @@ describe('ERC7579DelayedExecutor', function () { describe('cancelling', function () { beforeEach(async function () { await this.mockAccountFromEntrypoint.installModule(this.moduleType, this.mock.target, this.installData); - await this.mock.$_schedule(this.mockAccount.address, salt, this.mode, this.calldata); + 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 () { From 0e1db842cfdbfeb83f149238c237de2e9aedbf59 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 20:02:24 -0600 Subject: [PATCH 87/90] Add more tests. Fix off by 1 --- .../modules/ERC7579DelayedExecutor.sol | 2 +- .../modules/ERC7579DelayedExecutor.test.js | 58 +++++++++++++++++++ test/helpers/enums.js | 1 + 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 7f715f72..20776c42 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -123,7 +123,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { 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; + if (block.timestamp >= expiresAt) return OperationState.Expired; return OperationState.Ready; } diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index 4da4ae38..f26f618c 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -3,6 +3,7 @@ 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, @@ -72,6 +73,63 @@ describe('ERC7579DelayedExecutor', function () { 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 (cancelation)', 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(); 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'), }; From 0968b4883441f3c594dc4e37b95f094c4e292d6c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 20:03:08 -0600 Subject: [PATCH 88/90] Missing update --- contracts/account/modules/ERC7579DelayedExecutor.sol | 2 +- lib/@openzeppelin-contracts | 2 +- lib/@openzeppelin-contracts-upgradeable | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/account/modules/ERC7579DelayedExecutor.sol b/contracts/account/modules/ERC7579DelayedExecutor.sol index 20776c42..256ec1a3 100644 --- a/contracts/account/modules/ERC7579DelayedExecutor.sol +++ b/contracts/account/modules/ERC7579DelayedExecutor.sol @@ -132,7 +132,7 @@ abstract contract ERC7579DelayedExecutor is ERC7579Executor { * Set as default delay if not provided during {onInstall}. */ function minSetback() public view virtual returns (uint32) { - return 1 days; // Up to ~136 years + return 5 days; // Up to ~136 years } /// @dev Delay for a specific account. diff --git a/lib/@openzeppelin-contracts b/lib/@openzeppelin-contracts index bdf8affe..e3425168 160000 --- a/lib/@openzeppelin-contracts +++ b/lib/@openzeppelin-contracts @@ -1 +1 @@ -Subproject commit bdf8affec3a42686259f6afc9c9aeac39e905cf2 +Subproject commit e34251682bac9c3252af30e91e999f13dd098b9f diff --git a/lib/@openzeppelin-contracts-upgradeable b/lib/@openzeppelin-contracts-upgradeable index 3cc010dc..17f3f82d 160000 --- a/lib/@openzeppelin-contracts-upgradeable +++ b/lib/@openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 3cc010dc75de880f18a59172183453c590812324 +Subproject commit 17f3f82d1b47ada8b29431c359a5bc85e30d47db From aa8c0c2aeb989edf1ef0f1a6a5122daea9854cbf Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 20:05:39 -0600 Subject: [PATCH 89/90] reset libs --- lib/@openzeppelin-contracts | 2 +- lib/@openzeppelin-contracts-upgradeable | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/@openzeppelin-contracts b/lib/@openzeppelin-contracts index e3425168..bdf8affe 160000 --- a/lib/@openzeppelin-contracts +++ b/lib/@openzeppelin-contracts @@ -1 +1 @@ -Subproject commit e34251682bac9c3252af30e91e999f13dd098b9f +Subproject commit bdf8affec3a42686259f6afc9c9aeac39e905cf2 diff --git a/lib/@openzeppelin-contracts-upgradeable b/lib/@openzeppelin-contracts-upgradeable index 17f3f82d..3cc010dc 160000 --- a/lib/@openzeppelin-contracts-upgradeable +++ b/lib/@openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 17f3f82d1b47ada8b29431c359a5bc85e30d47db +Subproject commit 3cc010dc75de880f18a59172183453c590812324 From ba3aae483057800958d91244eb4f299d53b17354 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 May 2025 20:06:54 -0600 Subject: [PATCH 90/90] Codespell --- test/account/modules/ERC7579DelayedExecutor.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/account/modules/ERC7579DelayedExecutor.test.js b/test/account/modules/ERC7579DelayedExecutor.test.js index f26f618c..5be26ebd 100644 --- a/test/account/modules/ERC7579DelayedExecutor.test.js +++ b/test/account/modules/ERC7579DelayedExecutor.test.js @@ -111,7 +111,7 @@ describe('ERC7579DelayedExecutor', function () { ); }); - it('returns the correct state (cancelation)', async function () { + 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,