diff --git a/.changeset/clean-ways-push.md b/.changeset/clean-ways-push.md new file mode 100644 index 00000000000..3b214f46458 --- /dev/null +++ b/.changeset/clean-ways-push.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`AccountERC7579`: Extension of `Account` that implements support for ERC-7579 modules of type executor, validator, and fallback handler. diff --git a/.changeset/funny-years-yawn.md b/.changeset/funny-years-yawn.md new file mode 100644 index 00000000000..981f722e312 --- /dev/null +++ b/.changeset/funny-years-yawn.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`Account`: Added a simple ERC-4337 account implementation with minimal logic to process user operations. diff --git a/.changeset/lazy-poets-cheer.md b/.changeset/lazy-poets-cheer.md new file mode 100644 index 00000000000..99804cca94b --- /dev/null +++ b/.changeset/lazy-poets-cheer.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`SignerERC7702`: Implementation of `AbstractSigner` for Externally Owned Accounts (EOAs). Useful with ERC-7702. diff --git a/.changeset/lucky-donuts-scream.md b/.changeset/lucky-donuts-scream.md new file mode 100644 index 00000000000..aaeb29a5e35 --- /dev/null +++ b/.changeset/lucky-donuts-scream.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7739`: An abstract contract to validate signatures following the rehashing scheme from `ERC7739Utils`. diff --git a/.changeset/proud-tables-sip.md b/.changeset/proud-tables-sip.md new file mode 100644 index 00000000000..5a199d0ef41 --- /dev/null +++ b/.changeset/proud-tables-sip.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7739Utils`: Add a library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on the ERC-7739. diff --git a/.changeset/rotten-apes-lie.md b/.changeset/rotten-apes-lie.md new file mode 100644 index 00000000000..324d39314b0 --- /dev/null +++ b/.changeset/rotten-apes-lie.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`IERC7821`, `ERC7821`: Interface and logic for minimal batch execution. No support for additional `opData` is included. diff --git a/.changeset/strong-points-change.md b/.changeset/strong-points-change.md new file mode 100644 index 00000000000..8f37f38e2e3 --- /dev/null +++ b/.changeset/strong-points-change.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`AccountERC7579Hooked`: Extension of `AccountERC7579` that implements support for ERC-7579 hook modules. diff --git a/.changeset/tame-bears-mix.md b/.changeset/tame-bears-mix.md new file mode 100644 index 00000000000..5c9bcfa0b17 --- /dev/null +++ b/.changeset/tame-bears-mix.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`AbstractSigner`, `SignerECDSA`, `SignerP256`, and `SignerRSA`: Add an abstract contract and various implementations for contracts that deal with signature verification. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 3c5fc602e13..9f96c25ebec 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -13,7 +13,7 @@ runs: path: '**/node_modules' key: npm-v3-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies - run: npm ci + run: npm ci --legacy-peer-deps shell: bash if: steps.cache.outputs.cache-hit != 'true' - name: Install Foundry diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6aca7f30cb4..cab570955ed 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -118,6 +118,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up environment uses: ./.github/actions/setup + - run: rm package-lock.json package.json # Dependencies already installed - uses: crytic/slither-action@v0.4.1 codespell: diff --git a/contracts/account/Account.sol b/contracts/account/Account.sol new file mode 100644 index 00000000000..a58536df669 --- /dev/null +++ b/contracts/account/Account.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {PackedUserOperation, IAccount, IEntryPoint} from "../interfaces/draft-IERC4337.sol"; +import {ERC4337Utils} from "./utils/draft-ERC4337Utils.sol"; +import {AbstractSigner} from "../utils/cryptography/AbstractSigner.sol"; + +/** + * @dev A simple ERC4337 account implementation. This base implementation only includes the minimal logic to process + * user operations. + * + * Developers must implement the {AbstractSigner-_rawSignatureValidation} function to define the account's validation logic. + * + * NOTE: This core account doesn't include any mechanism for performing arbitrary external calls. This is an essential + * feature that all Account should have. We leave it up to the developers to implement the mechanism of their choice. + * Common choices include ERC-6900, ERC-7579 and ERC-7821 (among others). + * + * IMPORTANT: Implementing a mechanism to validate signatures is a security-sensitive operation as it may allow an + * attacker to bypass the account's security measures. Check out {SignerECDSA}, {SignerP256}, or {SignerRSA} for + * digital signature validation implementations. + * + * @custom:stateless + */ +abstract contract Account is AbstractSigner, IAccount { + /** + * @dev Unauthorized call to the account. + */ + error AccountUnauthorized(address sender); + + /** + * @dev Revert if the caller is not the entry point or the account itself. + */ + modifier onlyEntryPointOrSelf() { + _checkEntryPointOrSelf(); + _; + } + + /** + * @dev Revert if the caller is not the entry point. + */ + modifier onlyEntryPoint() { + _checkEntryPoint(); + _; + } + + /** + * @dev Canonical entry point for the account that forwards and validates user operations. + */ + function entryPoint() public view virtual returns (IEntryPoint) { + return ERC4337Utils.ENTRYPOINT_V08; + } + + /** + * @dev Return the account nonce for the canonical sequence. + */ + function getNonce() public view virtual returns (uint256) { + return getNonce(0); + } + + /** + * @dev Return the account nonce for a given sequence (key). + */ + function getNonce(uint192 key) public view virtual returns (uint256) { + return entryPoint().getNonce(address(this), key); + } + + /** + * @inheritdoc IAccount + */ + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 missingAccountFunds + ) public virtual onlyEntryPoint returns (uint256) { + uint256 validationData = _validateUserOp(userOp, userOpHash); + _payPrefund(missingAccountFunds); + return validationData; + } + + /** + * @dev Returns the validationData for a given user operation. By default, this checks the signature of the + * signable hash (produced by {_signableUserOpHash}) using the abstract signer ({AbstractSigner-_rawSignatureValidation}). + * + * NOTE: The userOpHash is assumed to be correct. Calling this function with a userOpHash that does not match the + * userOp will result in undefined behavior. + */ + function _validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) internal virtual returns (uint256) { + return + _rawSignatureValidation(_signableUserOpHash(userOp, userOpHash), userOp.signature) + ? ERC4337Utils.SIG_VALIDATION_SUCCESS + : ERC4337Utils.SIG_VALIDATION_FAILED; + } + + /** + * @dev Virtual function that returns the signable hash for a user operations. Since v0.8.0 of the entrypoint, + * `userOpHash` is an EIP-712 hash that can be signed directly. + */ + function _signableUserOpHash( + PackedUserOperation calldata /*userOp*/, + bytes32 userOpHash + ) internal view virtual returns (bytes32) { + return userOpHash; + } + + /** + * @dev Sends the missing funds for executing the user operation to the {entrypoint}. + * The `missingAccountFunds` must be defined by the entrypoint when calling {validateUserOp}. + */ + function _payPrefund(uint256 missingAccountFunds) internal virtual { + if (missingAccountFunds > 0) { + (bool success, ) = payable(msg.sender).call{value: missingAccountFunds}(""); + success; // Silence warning. The entrypoint should validate the result. + } + } + + /** + * @dev Ensures the caller is the {entrypoint}. + */ + function _checkEntryPoint() internal view virtual { + address sender = msg.sender; + if (sender != address(entryPoint())) { + revert AccountUnauthorized(sender); + } + } + + /** + * @dev Ensures the caller is the {entrypoint} or the account itself. + */ + function _checkEntryPointOrSelf() internal view virtual { + address sender = msg.sender; + if (sender != address(this) && sender != address(entryPoint())) { + revert AccountUnauthorized(sender); + } + } + + /** + * @dev Receive Ether. + */ + receive() external payable virtual {} +} diff --git a/contracts/account/README.adoc b/contracts/account/README.adoc index d2eb9db5ee9..714087666fa 100644 --- a/contracts/account/README.adoc +++ b/contracts/account/README.adoc @@ -1,9 +1,27 @@ = Account - [.readme-notice] -NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/account +NOTE: This document is better viewed at https://docs.openzeppelin.com/community-contracts/api/account + +This directory includes contracts to build accounts for ERC-4337. These include: + + * {Account}: An ERC-4337 smart account implementation that includes the core logic to process user operations. + * {AccountERC7579}: An extension of `Account` that implements support for ERC-7579 modules. + * {AccountERC7579Hooked}: An extension of `AccountERC7579` with support for a single hook module (type 4). + * {ERC7821}: Minimal batch executor implementation contracts. Useful to enable easy batch execution for smart contracts. + * {ERC4337Utils}: Utility functions for working with ERC-4337 user operations. + * {ERC7579Utils}: Utility functions for working with ERC-7579 modules and account modularity. + +== Core + +{{Account}} + +== Extensions + +{{AccountERC7579}} + +{{AccountERC7579Hooked}} -This directory includes contracts to build accounts for ERC-4337. +{{ERC7821}} == Utilities diff --git a/contracts/account/extensions/AccountERC7579.sol b/contracts/account/extensions/AccountERC7579.sol new file mode 100644 index 00000000000..e554bb96b0f --- /dev/null +++ b/contracts/account/extensions/AccountERC7579.sol @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {PackedUserOperation} from "../../interfaces/draft-IERC4337.sol"; +import {IERC1271} from "../../interfaces/IERC1271.sol"; +import {IERC7579Module, IERC7579Validator, IERC7579Execution, IERC7579AccountConfig, IERC7579ModuleConfig, MODULE_TYPE_VALIDATOR, MODULE_TYPE_EXECUTOR, MODULE_TYPE_FALLBACK} from "../../interfaces/draft-IERC7579.sol"; +import {ERC7579Utils, Mode, CallType, ExecType} from "../../account/utils/draft-ERC7579Utils.sol"; +import {EnumerableSet} from "../../utils/structs/EnumerableSet.sol"; +import {Bytes} from "../../utils/Bytes.sol"; +import {Packing} from "../../utils/Packing.sol"; +import {Address} from "../../utils/Address.sol"; +import {Calldata} from "../../utils/Calldata.sol"; +import {Account} from "../Account.sol"; + +/** + * @dev Extension of {Account} that implements support for ERC-7579 modules. + * + * To comply with the ERC-1271 support requirement, this contract defers signature validation to + * installed validator modules by calling {IERC7579Validator-isValidSignatureWithSender}. + * + * This contract does not implement validation logic for user operations since this functionality + * is often delegated to self-contained validation modules. Developers must install a validator module + * upon initialization (or any other mechanism to enable execution from the account): + * + * ```solidity + * contract MyAccountERC7579 is AccountERC7579, Initializable { + * function initializeAccount(address validator, bytes calldata validatorData) public initializer { + * _installModule(MODULE_TYPE_VALIDATOR, validator, validatorData); + * } + * } + * ``` + * + * [NOTE] + * ==== + * * Hook support is not included. See {AccountERC7579Hooked} for a version that hooks to execution. + * * Validator selection, when verifying either ERC-1271 signature or ERC-4337 UserOperation is implemented in + * internal virtual functions {_extractUserOpValidator} and {_extractSignatureValidator}. Both are implemented + * following common practices. However, this part is not standardized in ERC-7579 (or in any follow-up ERC). Some + * accounts may want to override these internal functions. + * * When combined with {ERC7739}, resolution ordering of {isValidSignature} may have an impact ({ERC7739} does not + * call super). Manual resolution might be necessary. + * * Static calls (using callType `0xfe`) are currently NOT supported. + * ==== + * + * WARNING: Removing all validator modules will render the account inoperable, as no user operations can be validated thereafter. + */ +abstract contract AccountERC7579 is Account, IERC1271, IERC7579Execution, IERC7579AccountConfig, IERC7579ModuleConfig { + using Bytes for *; + using ERC7579Utils for *; + using EnumerableSet for *; + using Packing for bytes32; + + EnumerableSet.AddressSet private _validators; + EnumerableSet.AddressSet private _executors; + mapping(bytes4 selector => address) private _fallbacks; + + /// @dev The account's {fallback} was called with a selector that doesn't have an installed handler. + error ERC7579MissingFallbackHandler(bytes4 selector); + + /// @dev Modifier that checks if the caller is an installed module of the given type. + modifier onlyModule(uint256 moduleTypeId, bytes calldata additionalContext) { + _checkModule(moduleTypeId, msg.sender, additionalContext); + _; + } + + /// @dev See {_fallback}. + fallback(bytes calldata) external payable virtual returns (bytes memory) { + return _fallback(); + } + + /// @inheritdoc IERC7579AccountConfig + function accountId() public view virtual returns (string memory) { + // vendorname.accountname.semver + return "@openzeppelin/community-contracts.AccountERC7579.v0.0.0"; + } + + /** + * @inheritdoc IERC7579AccountConfig + * + * @dev Supported call types: + * * Single (`0x00`): A single transaction execution. + * * Batch (`0x01`): A batch of transactions execution. + * * Delegate (`0xff`): A delegate call execution. + * + * Supported exec types: + * * Default (`0x00`): Default execution type (revert on failure). + * * Try (`0x01`): Try execution type (emits ERC7579TryExecuteFail on failure). + */ + function supportsExecutionMode(bytes32 encodedMode) public view virtual returns (bool) { + (CallType callType, ExecType execType, , ) = Mode.wrap(encodedMode).decodeMode(); + return + (callType == ERC7579Utils.CALLTYPE_SINGLE || + callType == ERC7579Utils.CALLTYPE_BATCH || + callType == ERC7579Utils.CALLTYPE_DELEGATECALL) && + (execType == ERC7579Utils.EXECTYPE_DEFAULT || execType == ERC7579Utils.EXECTYPE_TRY); + } + + /** + * @inheritdoc IERC7579AccountConfig + * + * @dev Supported module types: + * + * * Validator: A module used during the validation phase to determine if a transaction is valid and + * should be executed on the account. + * * Executor: A module that can execute transactions on behalf of the smart account via a callback. + * * Fallback Handler: A module that can extend the fallback functionality of a smart account. + */ + function supportsModule(uint256 moduleTypeId) public view virtual returns (bool) { + return + moduleTypeId == MODULE_TYPE_VALIDATOR || + moduleTypeId == MODULE_TYPE_EXECUTOR || + moduleTypeId == MODULE_TYPE_FALLBACK; + } + + /// @inheritdoc IERC7579ModuleConfig + function installModule( + uint256 moduleTypeId, + address module, + bytes calldata initData + ) public virtual onlyEntryPointOrSelf { + _installModule(moduleTypeId, module, initData); + } + + /// @inheritdoc IERC7579ModuleConfig + function uninstallModule( + uint256 moduleTypeId, + address module, + bytes calldata deInitData + ) public virtual onlyEntryPointOrSelf { + _uninstallModule(moduleTypeId, module, deInitData); + } + + /// @inheritdoc IERC7579ModuleConfig + function isModuleInstalled( + uint256 moduleTypeId, + address module, + bytes calldata additionalContext + ) public view virtual returns (bool) { + if (moduleTypeId == MODULE_TYPE_VALIDATOR) return _validators.contains(module); + if (moduleTypeId == MODULE_TYPE_EXECUTOR) return _executors.contains(module); + if (moduleTypeId == MODULE_TYPE_FALLBACK) return _fallbacks[bytes4(additionalContext[0:4])] == module; + return false; + } + + /// @inheritdoc IERC7579Execution + function execute(bytes32 mode, bytes calldata executionCalldata) public payable virtual onlyEntryPointOrSelf { + _execute(Mode.wrap(mode), executionCalldata); + } + + /// @inheritdoc IERC7579Execution + function executeFromExecutor( + bytes32 mode, + bytes calldata executionCalldata + ) + public + payable + virtual + onlyModule(MODULE_TYPE_EXECUTOR, Calldata.emptyBytes()) + returns (bytes[] memory returnData) + { + return _execute(Mode.wrap(mode), executionCalldata); + } + + /** + * @dev Implement ERC-1271 through IERC7579Validator modules. If module based validation fails, fallback to + * "native" validation by the abstract signer. + * + * NOTE: when combined with {ERC7739}, resolution ordering may have an impact ({ERC7739} does not call super). + * Manual resolution might be necessary. + */ + function isValidSignature(bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) { + // check signature length is enough for extraction + if (signature.length >= 20) { + (address module, bytes calldata innerSignature) = _extractSignatureValidator(signature); + // if module is not installed, skip + if (isModuleInstalled(MODULE_TYPE_VALIDATOR, module, Calldata.emptyBytes())) { + // try validation, skip any revert + try IERC7579Validator(module).isValidSignatureWithSender(msg.sender, hash, innerSignature) returns ( + bytes4 magic + ) { + return magic; + } catch {} + } + } + return bytes4(0xffffffff); + } + + /** + * @dev Validates a user operation with {_signableUserOpHash} and returns the validation data + * if the module specified by the first 20 bytes of the nonce key is installed. Falls back to + * {Account-_validateUserOp} otherwise. + * + * See {_extractUserOpValidator} for the module extraction logic. + */ + function _validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) internal virtual override returns (uint256) { + address module = _extractUserOpValidator(userOp); + return + isModuleInstalled(MODULE_TYPE_VALIDATOR, module, Calldata.emptyBytes()) + ? IERC7579Validator(module).validateUserOp(userOp, _signableUserOpHash(userOp, userOpHash)) + : super._validateUserOp(userOp, userOpHash); + } + + /** + * @dev ERC-7579 execution logic. See {supportsExecutionMode} for supported modes. + * + * Reverts if the call type is not supported. + */ + function _execute( + Mode mode, + bytes calldata executionCalldata + ) internal virtual returns (bytes[] memory returnData) { + (CallType callType, ExecType execType, , ) = mode.decodeMode(); + if (callType == ERC7579Utils.CALLTYPE_SINGLE) return executionCalldata.execSingle(execType); + if (callType == ERC7579Utils.CALLTYPE_BATCH) return executionCalldata.execBatch(execType); + if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) return executionCalldata.execDelegateCall(execType); + revert ERC7579Utils.ERC7579UnsupportedCallType(callType); + } + + /** + * @dev Installs a module of the given type with the given initialization data. + * + * For the fallback module type, the `initData` is expected to be the (packed) concatenation of a 4-byte + * selector and the rest of the data to be sent to the handler when calling {IERC7579Module-onInstall}. + * + * Requirements: + * + * * Module type must be supported. See {supportsModule}. Reverts with {ERC7579UnsupportedModuleType}. + * * Module must be of the given type. Reverts with {ERC7579MismatchedModuleTypeId}. + * * Module must not be already installed. Reverts with {ERC7579AlreadyInstalledModule}. + * + * Emits a {ModuleInstalled} event. + */ + function _installModule(uint256 moduleTypeId, address module, bytes memory initData) internal virtual { + require(supportsModule(moduleTypeId), ERC7579Utils.ERC7579UnsupportedModuleType(moduleTypeId)); + require( + IERC7579Module(module).isModuleType(moduleTypeId), + ERC7579Utils.ERC7579MismatchedModuleTypeId(moduleTypeId, module) + ); + + if (moduleTypeId == MODULE_TYPE_VALIDATOR) { + require(_validators.add(module), ERC7579Utils.ERC7579AlreadyInstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == MODULE_TYPE_EXECUTOR) { + require(_executors.add(module), ERC7579Utils.ERC7579AlreadyInstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == MODULE_TYPE_FALLBACK) { + bytes4 selector; + (selector, initData) = _decodeFallbackData(initData); + require( + _fallbacks[selector] == address(0), + ERC7579Utils.ERC7579AlreadyInstalledModule(moduleTypeId, module) + ); + _fallbacks[selector] = module; + } + + IERC7579Module(module).onInstall(initData); + emit ModuleInstalled(moduleTypeId, module); + } + + /** + * @dev Uninstalls a module of the given type with the given de-initialization data. + * + * For the fallback module type, the `deInitData` is expected to be the (packed) concatenation of a 4-byte + * selector and the rest of the data to be sent to the handler when calling {IERC7579Module-onUninstall}. + * + * Requirements: + * + * * Module must be already installed. Reverts with {ERC7579UninstalledModule} otherwise. + */ + function _uninstallModule(uint256 moduleTypeId, address module, bytes memory deInitData) internal virtual { + require(supportsModule(moduleTypeId), ERC7579Utils.ERC7579UnsupportedModuleType(moduleTypeId)); + + if (moduleTypeId == MODULE_TYPE_VALIDATOR) { + require(_validators.remove(module), ERC7579Utils.ERC7579UninstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == MODULE_TYPE_EXECUTOR) { + require(_executors.remove(module), ERC7579Utils.ERC7579UninstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == MODULE_TYPE_FALLBACK) { + bytes4 selector; + (selector, deInitData) = _decodeFallbackData(deInitData); + require( + _fallbackHandler(selector) == module && module != address(0), + ERC7579Utils.ERC7579UninstalledModule(moduleTypeId, module) + ); + delete _fallbacks[selector]; + } + + IERC7579Module(module).onUninstall(deInitData); + emit ModuleUninstalled(moduleTypeId, module); + } + + /** + * @dev Fallback function that delegates the call to the installed handler for the given selector. + * + * Reverts with {ERC7579MissingFallbackHandler} if the handler is not installed. + * + * Calls the handler with the original `msg.sender` appended at the end of the calldata following + * the ERC-2771 format. + */ + function _fallback() internal virtual returns (bytes memory) { + address handler = _fallbackHandler(msg.sig); + require(handler != address(0), ERC7579MissingFallbackHandler(msg.sig)); + + // From https://eips.ethereum.org/EIPS/eip-7579#fallback[ERC-7579 specifications]: + // - MUST utilize ERC-2771 to add the original msg.sender to the calldata sent to the fallback handler + // - MUST use call to invoke the fallback handler + (bool success, bytes memory returndata) = handler.call{value: msg.value}( + abi.encodePacked(msg.data, msg.sender) + ); + + if (success) return returndata; + + assembly ("memory-safe") { + revert(add(returndata, 0x20), mload(returndata)) + } + } + + /// @dev Returns the fallback handler for the given selector. Returns `address(0)` if not installed. + function _fallbackHandler(bytes4 selector) internal view virtual returns (address) { + return _fallbacks[selector]; + } + + /// @dev Checks if the module is installed. Reverts if the module is not installed. + function _checkModule( + uint256 moduleTypeId, + address module, + bytes calldata additionalContext + ) internal view virtual { + require( + isModuleInstalled(moduleTypeId, module, additionalContext), + ERC7579Utils.ERC7579UninstalledModule(moduleTypeId, module) + ); + } + + /** + * @dev Extracts the nonce validator from the user operation. + * + * To construct a nonce key, set nonce as follows: + * + * ``` + * | | + * ``` + * NOTE: The default behavior of this function replicates the behavior of + * https://github.com/rhinestonewtf/safe7579/blob/bb29e8b1a66658790c4169e72608e27d220f79be/src/Safe7579.sol#L266[Safe adapter], + * https://github.com/etherspot/etherspot-prime-contracts/blob/cfcdb48c4172cea0d66038324c0bae3288aa8caa/src/modular-etherspot-wallet/wallet/ModularEtherspotWallet.sol#L227[Etherspot's Prime Account], and + * https://github.com/erc7579/erc7579-implementation/blob/16138d1afd4e9711f6c1425133538837bd7787b5/src/MSAAdvanced.sol#L247[ERC7579 reference implementation]. + * + * This is not standardized in ERC-7579 (or in any follow-up ERC). Some accounts may want to override these internal functions. + * + * For example, https://github.com/bcnmy/nexus/blob/54f4e19baaff96081a8843672977caf712ef19f4/contracts/lib/NonceLib.sol#L17[Biconomy's Nexus] + * uses a similar yet incompatible approach (the validator address is also part of the nonce, but not at the same location) + */ + function _extractUserOpValidator(PackedUserOperation calldata userOp) internal pure virtual returns (address) { + return address(bytes32(userOp.nonce).extract_32_20(0)); + } + + /** + * @dev Extracts the signature validator from the signature. + * + * To construct a signature, set the first 20 bytes as the module address and the remaining bytes as the + * signature data: + * + * ``` + * | + * ``` + * + * NOTE: The default behavior of this function replicates the behavior of + * https://github.com/rhinestonewtf/safe7579/blob/bb29e8b1a66658790c4169e72608e27d220f79be/src/Safe7579.sol#L350[Safe adapter], + * https://github.com/bcnmy/nexus/blob/54f4e19baaff96081a8843672977caf712ef19f4/contracts/Nexus.sol#L239[Biconomy's Nexus], + * https://github.com/etherspot/etherspot-prime-contracts/blob/cfcdb48c4172cea0d66038324c0bae3288aa8caa/src/modular-etherspot-wallet/wallet/ModularEtherspotWallet.sol#L252[Etherspot's Prime Account], and + * https://github.com/erc7579/erc7579-implementation/blob/16138d1afd4e9711f6c1425133538837bd7787b5/src/MSAAdvanced.sol#L296[ERC7579 reference implementation]. + * + * This is not standardized in ERC-7579 (or in any follow-up ERC). Some accounts may want to override these internal functions. + */ + function _extractSignatureValidator( + bytes calldata signature + ) internal pure virtual returns (address module, bytes calldata innerSignature) { + return (address(bytes20(signature[0:20])), signature[20:]); + } + + /** + * @dev Extract the function selector from initData/deInitData for MODULE_TYPE_FALLBACK + * + * NOTE: If we had calldata here, we could use calldata slice which are cheaper to manipulate and don't require + * actual copy. However, this would require `_installModule` to get a calldata bytes object instead of a memory + * bytes object. This would prevent calling `_installModule` from a contract constructor and would force the use + * of external initializers. That may change in the future, as most accounts will probably be deployed as + * clones/proxy/ERC-7702 delegates and therefore rely on initializers anyway. + */ + function _decodeFallbackData( + bytes memory data + ) internal pure virtual returns (bytes4 selector, bytes memory remaining) { + return (bytes4(data), data.slice(4)); + } + + /// @dev By default, only use the modules for validation of userOp and signature. Disable raw signatures. + function _rawSignatureValidation( + bytes32 /*hash*/, + bytes calldata /*signature*/ + ) internal view virtual override returns (bool) { + return false; + } +} diff --git a/contracts/account/extensions/AccountERC7579Hooked.sol b/contracts/account/extensions/AccountERC7579Hooked.sol new file mode 100644 index 00000000000..9666f3cdae7 --- /dev/null +++ b/contracts/account/extensions/AccountERC7579Hooked.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7579Hook, MODULE_TYPE_HOOK} from "../../interfaces/draft-IERC7579.sol"; +import {ERC7579Utils, Mode} from "../../account/utils/draft-ERC7579Utils.sol"; +import {AccountERC7579} from "./AccountERC7579.sol"; + +/** + * @dev Extension of {AccountERC7579} with support for a single hook module (type 4). + * + * If installed, this extension will call the hook module's {IERC7579Hook-preCheck} before executing any operation + * with {_execute} (including {execute} and {executeFromExecutor} by default) and {IERC7579Hook-postCheck} thereafter. + * + * NOTE: Hook modules break the check-effect-interaction pattern. In particular, the {IERC7579Hook-preCheck} hook can + * lead to potentially dangerous reentrancy. Using the `withHook()` modifier is safe if no effect is performed + * before the preHook or after the postHook. That is the case on all functions here, but it may not be the case if + * functions that have this modifier are overridden. Developers should be extremely careful when implementing hook + * modules or further overriding functions that involve hooks. + */ +abstract contract AccountERC7579Hooked is AccountERC7579 { + address private _hook; + + /// @dev A hook module is already present. This contract only supports one hook module. + error ERC7579HookModuleAlreadyPresent(address hook); + + /** + * @dev Calls {IERC7579Hook-preCheck} before executing the modified function and {IERC7579Hook-postCheck} + * thereafter. + */ + modifier withHook() { + address hook_ = hook(); + bytes memory hookData; + + // slither-disable-next-line reentrancy-no-eth + if (hook_ != address(0)) hookData = IERC7579Hook(hook_).preCheck(msg.sender, msg.value, msg.data); + _; + if (hook_ != address(0)) IERC7579Hook(hook_).postCheck(hookData); + } + + /// @inheritdoc AccountERC7579 + function accountId() public view virtual override returns (string memory) { + // vendorname.accountname.semver + return "@openzeppelin/community-contracts.AccountERC7579Hooked.v0.0.0"; + } + + /// @dev Returns the hook module address if installed, or `address(0)` otherwise. + function hook() public view virtual returns (address) { + return _hook; + } + + /// @dev Supports hook modules. See {AccountERC7579-supportsModule} + function supportsModule(uint256 moduleTypeId) public view virtual override returns (bool) { + return moduleTypeId == MODULE_TYPE_HOOK || super.supportsModule(moduleTypeId); + } + + /// @inheritdoc AccountERC7579 + function isModuleInstalled( + uint256 moduleTypeId, + address module, + bytes calldata data + ) public view virtual override returns (bool) { + return + (moduleTypeId == MODULE_TYPE_HOOK && module == hook()) || + super.isModuleInstalled(moduleTypeId, module, data); + } + + /// @dev Installs a module with support for hook modules. See {AccountERC7579-_installModule} + function _installModule( + uint256 moduleTypeId, + address module, + bytes memory initData + ) internal virtual override withHook { + if (moduleTypeId == MODULE_TYPE_HOOK) { + require(_hook == address(0), ERC7579HookModuleAlreadyPresent(_hook)); + _hook = module; + } + super._installModule(moduleTypeId, module, initData); + } + + /// @dev Uninstalls a module with support for hook modules. See {AccountERC7579-_uninstallModule} + function _uninstallModule( + uint256 moduleTypeId, + address module, + bytes memory deInitData + ) internal virtual override withHook { + if (moduleTypeId == MODULE_TYPE_HOOK) { + require(_hook == module, ERC7579Utils.ERC7579UninstalledModule(moduleTypeId, module)); + _hook = address(0); + } + super._uninstallModule(moduleTypeId, module, deInitData); + } + + /// @dev Hooked version of {AccountERC7579-_execute}. + function _execute( + Mode mode, + bytes calldata executionCalldata + ) internal virtual override withHook returns (bytes[] memory) { + return super._execute(mode, executionCalldata); + } + + /// @dev Hooked version of {AccountERC7579-_fallback}. + function _fallback() internal virtual override withHook returns (bytes memory) { + return super._fallback(); + } +} diff --git a/contracts/account/extensions/ERC7821.sol b/contracts/account/extensions/ERC7821.sol new file mode 100644 index 00000000000..c442d868234 --- /dev/null +++ b/contracts/account/extensions/ERC7821.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC7579Utils, Mode, CallType, ExecType, ModeSelector} from "../utils/draft-ERC7579Utils.sol"; +import {IERC7821} from "../../interfaces/IERC7821.sol"; +import {Account} from "../Account.sol"; + +/** + * @dev Minimal batch executor following ERC-7821. + * + * Only supports supports single batch mode (`0x01000000000000000000`). Does not support optional "opData". + * + * @custom:stateless + */ +abstract contract ERC7821 is IERC7821 { + using ERC7579Utils for *; + + error UnsupportedExecutionMode(); + + /** + * @dev Executes the calls in `executionData` with no optional `opData` support. + * + * NOTE: Access to this function is controlled by {_erc7821AuthorizedExecutor}. Changing access permissions, for + * example to approve calls by the ERC-4337 entrypoint, should be implemented by overriding it. + * + * Reverts and bubbles up error if any call fails. + */ + function execute(bytes32 mode, bytes calldata executionData) public payable virtual { + if (!_erc7821AuthorizedExecutor(msg.sender, mode, executionData)) + revert Account.AccountUnauthorized(msg.sender); + if (!supportsExecutionMode(mode)) revert UnsupportedExecutionMode(); + executionData.execBatch(ERC7579Utils.EXECTYPE_DEFAULT); + } + + /// @inheritdoc IERC7821 + function supportsExecutionMode(bytes32 mode) public view virtual returns (bool result) { + (CallType callType, ExecType execType, ModeSelector modeSelector, ) = Mode.wrap(mode).decodeMode(); + return + callType == ERC7579Utils.CALLTYPE_BATCH && + execType == ERC7579Utils.EXECTYPE_DEFAULT && + modeSelector == ModeSelector.wrap(0x00000000); + } + + /** + * @dev Access control mechanism for the {execute} function. + * By default, only the contract itself is allowed to execute. + * + * Override this function to implement custom access control, for example to allow the + * ERC-4337 entrypoint to execute. + * + * ```solidity + * function _erc7821AuthorizedExecutor( + * address caller, + * bytes32 mode, + * bytes calldata executionData + * ) internal view virtual override returns (bool) { + * return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + * } + * ``` + */ + function _erc7821AuthorizedExecutor( + address caller, + bytes32 /* mode */, + bytes calldata /* executionData */ + ) internal view virtual returns (bool) { + return caller == address(this); + } +} diff --git a/contracts/interfaces/IERC7821.sol b/contracts/interfaces/IERC7821.sol new file mode 100644 index 00000000000..1607caa5038 --- /dev/null +++ b/contracts/interfaces/IERC7821.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @dev Interface for minimal batch executor. + */ +interface IERC7821 { + /** + * @dev Executes the calls in `executionData`. + * Reverts and bubbles up error if any call fails. + * + * `executionData` encoding: + * - If `opData` is empty, `executionData` is simply `abi.encode(calls)`. + * - Else, `executionData` is `abi.encode(calls, opData)`. + * See: https://eips.ethereum.org/EIPS/eip-7579 + * + * Supported modes: + * - `bytes32(0x01000000000000000000...)`: does not support optional `opData`. + * - `bytes32(0x01000000000078210001...)`: supports optional `opData`. + * + * Authorization checks: + * - If `opData` is empty, the implementation SHOULD require that + * `msg.sender == address(this)`. + * - If `opData` is not empty, the implementation SHOULD use the signature + * encoded in `opData` to determine if the caller can perform the execution. + * + * `opData` may be used to store additional data for authentication, + * paymaster data, gas limits, etc. + */ + function execute(bytes32 mode, bytes calldata executionData) external payable; + + /** + * @dev This function is provided for frontends to detect support. + * Only returns true for: + * - `bytes32(0x01000000000000000000...)`: does not support optional `opData`. + * - `bytes32(0x01000000000078210001...)`: supports optional `opData`. + */ + function supportsExecutionMode(bytes32 mode) external view returns (bool); +} diff --git a/contracts/interfaces/IERC7913.sol b/contracts/interfaces/IERC7913.sol new file mode 100644 index 00000000000..58e334095fc --- /dev/null +++ b/contracts/interfaces/IERC7913.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @dev Signature verifier interface. + */ +interface IERC7913SignatureVerifier { + /** + * @dev Verifies `signature` as a valid signature of `hash` by `key`. + * + * MUST return the bytes4 magic value IERC7913SignatureVerifier.verify.selector if the signature is valid. + * SHOULD return 0xffffffff or revert if the signature is not valid. + * SHOULD return 0xffffffff or revert if the key is empty + */ + function verify(bytes calldata key, bytes32 hash, bytes calldata signature) external view returns (bytes4); +} diff --git a/contracts/mocks/CallReceiverMock.sol b/contracts/mocks/CallReceiverMock.sol index e371c7db800..8b5ec7adb0f 100644 --- a/contracts/mocks/CallReceiverMock.sol +++ b/contracts/mocks/CallReceiverMock.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.20; contract CallReceiverMock { event MockFunctionCalled(); event MockFunctionCalledWithArgs(uint256 a, uint256 b); + event MockFunctionCalledExtra(address caller, uint256 value); uint256[] private _array; @@ -58,6 +59,10 @@ contract CallReceiverMock { } return "0x1234"; } + + function mockFunctionExtra() public payable { + emit MockFunctionCalledExtra(msg.sender, msg.value); + } } contract CallReceiverMockTrustingForwarder is CallReceiverMock { diff --git a/contracts/mocks/account/AccountMock.sol b/contracts/mocks/account/AccountMock.sol new file mode 100644 index 00000000000..f73f8a52ce1 --- /dev/null +++ b/contracts/mocks/account/AccountMock.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {Account} from "../../account/Account.sol"; +import {AccountERC7579} from "../../account/extensions/AccountERC7579.sol"; +import {AccountERC7579Hooked} from "../../account/extensions/AccountERC7579Hooked.sol"; +import {ERC721Holder} from "../../token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "../../token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC4337Utils} from "../../account/utils/draft-ERC4337Utils.sol"; +import {ERC7739} from "../../utils/cryptography/ERC7739.sol"; +import {ERC7821} from "../../account/extensions/ERC7821.sol"; +import {MODULE_TYPE_VALIDATOR} from "../../interfaces/draft-IERC7579.sol"; +import {PackedUserOperation} from "../../interfaces/draft-IERC4337.sol"; +import {AbstractSigner} from "../../utils/cryptography/AbstractSigner.sol"; +import {SignerECDSA} from "../../utils/cryptography/SignerECDSA.sol"; +import {SignerP256} from "../../utils/cryptography/SignerP256.sol"; +import {SignerRSA} from "../../utils/cryptography/SignerRSA.sol"; +import {SignerERC7702} from "../../utils/cryptography/SignerERC7702.sol"; + +abstract contract AccountMock is Account, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + /// Validates a user operation with a boolean signature. + function _rawSignatureValidation(bytes32 hash, bytes calldata signature) internal pure override returns (bool) { + return signature.length >= 32 && bytes32(signature) == hash; + } + + /// @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); + } +} + +abstract contract AccountECDSAMock is Account, SignerECDSA, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + constructor(address signerAddr) { + _setSigner(signerAddr); + } + + /// @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); + } +} + +abstract contract AccountP256Mock is Account, SignerP256, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + constructor(bytes32 qx, bytes32 qy) { + _setSigner(qx, qy); + } + + /// @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); + } +} + +abstract contract AccountRSAMock is Account, SignerRSA, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + constructor(bytes memory e, bytes memory n) { + _setSigner(e, n); + } + + /// @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); + } +} + +abstract contract AccountERC7702Mock is Account, SignerERC7702, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + /// @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); + } +} + +abstract contract AccountERC7702WithModulesMock is + Account, + AccountERC7579, + SignerERC7702, + ERC7739, + ERC721Holder, + ERC1155Holder +{ + function _validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) internal virtual override(Account, AccountERC7579) returns (uint256) { + return super._validateUserOp(userOp, userOpHash); + } + + /// @dev Resolve implementation of ERC-1271 by both ERC7739 and AccountERC7579 to support both schemes. + function isValidSignature( + bytes32 hash, + bytes calldata signature + ) public view virtual override(ERC7739, AccountERC7579) returns (bytes4) { + // ERC-7739 can return the fn selector (success), 0xffffffff (invalid) or 0x77390001 (detection). + // If the return is 0xffffffff, we fallback to validation using ERC-7579 modules. + bytes4 erc7739magic = ERC7739.isValidSignature(hash, signature); + return erc7739magic == bytes4(0xffffffff) ? AccountERC7579.isValidSignature(hash, signature) : erc7739magic; + } + + /// @dev Enable signature using the ERC-7702 signer. + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override(AbstractSigner, AccountERC7579, SignerERC7702) returns (bool) { + return SignerERC7702._rawSignatureValidation(hash, signature); + } +} + +abstract contract AccountERC7579Mock is AccountERC7579 { + constructor(address validator, bytes memory initData) { + _installModule(MODULE_TYPE_VALIDATOR, validator, initData); + } +} + +abstract contract AccountERC7579HookedMock is AccountERC7579Hooked { + constructor(address validator, bytes memory initData) { + _installModule(MODULE_TYPE_VALIDATOR, validator, initData); + } +} diff --git a/contracts/mocks/account/modules/ERC7579Mock.sol b/contracts/mocks/account/modules/ERC7579Mock.sol new file mode 100644 index 00000000000..bacaf26d8f5 --- /dev/null +++ b/contracts/mocks/account/modules/ERC7579Mock.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {MODULE_TYPE_HOOK, MODULE_TYPE_FALLBACK, MODULE_TYPE_VALIDATOR, IERC7579Hook, IERC7579Module, IERC7579Validator} from "../../../interfaces/draft-IERC7579.sol"; +import {SignatureChecker} from "../../../utils/cryptography/SignatureChecker.sol"; +import {PackedUserOperation} from "../../../interfaces/draft-IERC4337.sol"; +import {IERC1271} from "../../../interfaces/IERC1271.sol"; +import {ERC4337Utils} from "../../../account/utils/draft-ERC4337Utils.sol"; + +abstract contract ERC7579ModuleMock is IERC7579Module { + uint256 private _moduleTypeId; + + event ModuleInstalledReceived(address account, bytes data); + event ModuleUninstalledReceived(address account, bytes data); + + constructor(uint256 moduleTypeId) { + _moduleTypeId = moduleTypeId; + } + + function onInstall(bytes calldata data) public virtual { + emit ModuleInstalledReceived(msg.sender, data); + } + + function onUninstall(bytes calldata data) public virtual { + emit ModuleUninstalledReceived(msg.sender, data); + } + + function isModuleType(uint256 moduleTypeId) external view returns (bool) { + return moduleTypeId == _moduleTypeId; + } +} + +abstract contract ERC7579HookMock is ERC7579ModuleMock(MODULE_TYPE_HOOK), IERC7579Hook { + event PreCheck(address sender, uint256 value, bytes data); + event PostCheck(bytes hookData); + + function preCheck( + address msgSender, + uint256 value, + bytes calldata msgData + ) external returns (bytes memory hookData) { + emit PreCheck(msgSender, value, msgData); + return msgData; + } + + function postCheck(bytes calldata hookData) external { + emit PostCheck(hookData); + } +} + +abstract contract ERC7579FallbackHandlerMock is ERC7579ModuleMock(MODULE_TYPE_FALLBACK) { + event ERC7579FallbackHandlerMockCalled(address account, address sender, uint256 value, bytes data); + + error ERC7579FallbackHandlerMockRevert(); + + function _msgAccount() internal view returns (address) { + return msg.sender; + } + + function _msgSender() internal pure returns (address) { + return address(bytes20(msg.data[msg.data.length - 20:])); + } + + function _msgData() internal pure returns (bytes calldata) { + return msg.data[:msg.data.length - 20]; + } + + function callPayable() public payable { + emit ERC7579FallbackHandlerMockCalled(_msgAccount(), _msgSender(), msg.value, _msgData()); + } + + function callView() public view returns (address, address) { + return (_msgAccount(), _msgSender()); + } + + function callRevert() public pure { + revert ERC7579FallbackHandlerMockRevert(); + } +} + +abstract contract ERC7579ValidatorMock is ERC7579ModuleMock(MODULE_TYPE_VALIDATOR), IERC7579Validator { + mapping(address sender => address signer) private _associatedSigners; + + function onInstall(bytes calldata data) public virtual override(IERC7579Module, ERC7579ModuleMock) { + _associatedSigners[msg.sender] = address(bytes20(data[0:20])); + super.onInstall(data); + } + + function onUninstall(bytes calldata data) public virtual override(IERC7579Module, ERC7579ModuleMock) { + delete _associatedSigners[msg.sender]; + super.onUninstall(data); + } + + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) public view virtual returns (uint256) { + return + SignatureChecker.isValidSignatureNow(_associatedSigners[msg.sender], userOpHash, userOp.signature) + ? ERC4337Utils.SIG_VALIDATION_SUCCESS + : ERC4337Utils.SIG_VALIDATION_FAILED; + } + + function isValidSignatureWithSender( + address /*sender*/, + bytes32 hash, + bytes calldata signature + ) public view virtual returns (bytes4) { + return + SignatureChecker.isValidSignatureNow(_associatedSigners[msg.sender], hash, signature) + ? IERC1271.isValidSignature.selector + : bytes4(0xffffffff); + } +} diff --git a/contracts/mocks/utils/cryptography/ERC7739Mock.sol b/contracts/mocks/utils/cryptography/ERC7739Mock.sol new file mode 100644 index 00000000000..1f2416df2c6 --- /dev/null +++ b/contracts/mocks/utils/cryptography/ERC7739Mock.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ECDSA} from "../../../utils/cryptography/ECDSA.sol"; +import {EIP712} from "../../../utils/cryptography/EIP712.sol"; +import {ERC7739} from "../../../utils/cryptography/ERC7739.sol"; +import {SignerECDSA} from "../../../utils/cryptography/SignerECDSA.sol"; +import {SignerP256} from "../../../utils/cryptography/SignerP256.sol"; +import {SignerRSA} from "../../../utils/cryptography/SignerRSA.sol"; + +contract ERC7739ECDSAMock is ERC7739, SignerECDSA { + constructor(address signerAddr) EIP712("ERC7739ECDSA", "1") { + _setSigner(signerAddr); + } +} + +contract ERC7739P256Mock is ERC7739, SignerP256 { + constructor(bytes32 qx, bytes32 qy) EIP712("ERC7739P256", "1") { + _setSigner(qx, qy); + } +} + +contract ERC7739RSAMock is ERC7739, SignerRSA { + constructor(bytes memory e, bytes memory n) EIP712("ERC7739RSA", "1") { + _setSigner(e, n); + } +} diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index 74b26b236cc..411ffdc3f51 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -23,6 +23,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {BitMaps}: A simple library to manage boolean value mapped to a numerical index in an efficient way. * {EnumerableMap}: A type like Solidity's https://solidity.readthedocs.io/en/latest/types.html#mapping-types[`mapping`], but with key-value _enumeration_: this will let you know how many entries a mapping has, and iterate over them (which is not possible with `mapping`). * {EnumerableSet}: Like {EnumerableMap}, but for https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets]. Can be used to store privileged accounts, issued IDs, etc. + * {EnumerableSetExtended} and {EnumerableMapExtended}: Extensions of the `EnumerableSet` and `EnumerableMap` libraries with more types, including non-value types. * {DoubleEndedQueue}: An implementation of a https://en.wikipedia.org/wiki/Double-ended_queue[double ended queue] whose values can be added or removed from both sides. Useful for FIFO and LIFO structures. * {CircularBuffer}: A data structure to store the last N values pushed to it. * {Checkpoints}: A data structure to store values mapped to a strictly increasing key. Can be used for storing and accessing values over time. @@ -46,6 +47,14 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {Comparators}: A library that contains comparator functions to use with the {Heap} library. * {CAIP2}, {CAIP10}: Libraries for formatting and parsing CAIP-2 and CAIP-10 identifiers. * {Blockhash}: A library for accessing historical block hashes beyond the standard 256 block limit utilizing EIP-2935's historical blockhash functionality. + * {AbstractSigner}: Abstract contract for internal signature validation in smart contracts. + * {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}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. + * {SignerERC7702}: Implementation of {AbstractSigner} that validates signatures using the contract's own address as the signer, useful for delegated accounts following EIP-7702. + * {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 [NOTE] ==== @@ -78,6 +87,36 @@ Because Solidity does not support generic types, {EnumerableMap} and {Enumerable {{MerkleProof}} +{{ERC7739}} + +{{ERC7739Utils}} + +=== Abstract Signers + +{{AbstractSigner}} + +{{SignerECDSA}} + +{{SignerP256}} + +{{SignerERC7702}} + +{{SignerRSA}} + +{{SignerERC7913}} + +{{MultiSignerERC7913}} + +{{MultiSignerERC7913Weighted}} + +=== ERC-7913 + +{{ERC7913Utils}} + +{{ERC7913SignatureVerifierP256}} + +{{ERC7913SignatureVerifierRSA}} + == Security {{ReentrancyGuard}} @@ -108,8 +147,12 @@ Ethereum contracts have no native concept of an interface, so applications must {{EnumerableMap}} +{{EnumerableMapExtended}} + {{EnumerableSet}} +{{EnumerableSetExtended}} + {{DoubleEndedQueue}} {{CircularBuffer}} diff --git a/contracts/utils/cryptography/AbstractSigner.sol b/contracts/utils/cryptography/AbstractSigner.sol new file mode 100644 index 00000000000..40803518c68 --- /dev/null +++ b/contracts/utils/cryptography/AbstractSigner.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +/** + * @dev Abstract contract for signature validation. + * + * Developers must implement {_rawSignatureValidation} and use it as the lowest-level signature validation mechanism. + * + * @custom:stateless + */ +abstract contract AbstractSigner { + /** + * @dev Signature validation algorithm. + * + * WARNING: Implementing a signature validation algorithm is a security-sensitive operation as it involves + * cryptographic verification. It is important to review and test thoroughly before deployment. Consider + * using one of the signature verification libraries (xref:api:utils#ECDSA[ECDSA], xref:api:utils#P256[P256] + * or xref:api:utils#RSA[RSA]). + */ + function _rawSignatureValidation(bytes32 hash, bytes calldata signature) internal view virtual returns (bool); +} diff --git a/contracts/utils/cryptography/ERC7739.sol b/contracts/utils/cryptography/ERC7739.sol new file mode 100644 index 00000000000..eb4f47f37c3 --- /dev/null +++ b/contracts/utils/cryptography/ERC7739.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC1271} from "../../interfaces/IERC1271.sol"; +import {EIP712} from "../cryptography/EIP712.sol"; +import {MessageHashUtils} from "../cryptography/MessageHashUtils.sol"; +import {ShortStrings} from "../ShortStrings.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; +import {ERC7739Utils} from "./ERC7739Utils.sol"; + +/** + * @dev Validates signatures wrapping the message hash in a nested EIP712 type. See {ERC7739Utils}. + * + * Linking the signature to the EIP-712 domain separator is a security measure to prevent signature replay across different + * EIP-712 domains (e.g. a single offchain owner of multiple contracts). + * + * This contract requires implementing the {_rawSignatureValidation} function, which passes the wrapped message hash, + * which may be either an typed data or a personal sign nested type. + * + * NOTE: xref:api:utils#EIP712[EIP-712] uses xref:api:utils#ShortStrings[ShortStrings] to optimize gas + * costs for short strings (up to 31 characters). Consider that strings longer than that will use storage, + * which may limit the ability of the signer to be used within the ERC-4337 validation phase (due to + * https://eips.ethereum.org/EIPS/eip-7562#storage-rules[ERC-7562 storage access rules]). + */ +abstract contract ERC7739 is AbstractSigner, EIP712, IERC1271 { + using ERC7739Utils for *; + using MessageHashUtils for bytes32; + + /** + * @dev Attempts validating the signature in a nested EIP-712 type. + * + * A nested EIP-712 type might be presented in 2 different ways: + * + * - As a nested EIP-712 typed data + * - As a _personal_ signature (an EIP-712 mimic of the `eth_personalSign` for a smart contract) + */ + function isValidSignature(bytes32 hash, bytes calldata signature) public view virtual returns (bytes4 result) { + // For the hash `0x7739773977397739773977397739773977397739773977397739773977397739` and an empty signature, + // we return the magic value `0x77390001` as it's assumed impossible to find a preimage for it that can be used + // maliciously. Useful for simulation purposes and to validate whether the contract supports ERC-7739. + return + (_isValidNestedTypedDataSignature(hash, signature) || _isValidNestedPersonalSignSignature(hash, signature)) + ? IERC1271.isValidSignature.selector + : (hash == 0x7739773977397739773977397739773977397739773977397739773977397739 && signature.length == 0) + ? bytes4(0x77390001) + : bytes4(0xffffffff); + } + + /** + * @dev Nested personal signature verification. + */ + function _isValidNestedPersonalSignSignature(bytes32 hash, bytes calldata signature) private view returns (bool) { + return _rawSignatureValidation(_domainSeparatorV4().toTypedDataHash(hash.personalSignStructHash()), signature); + } + + /** + * @dev Nested EIP-712 typed data verification. + */ + function _isValidNestedTypedDataSignature( + bytes32 hash, + bytes calldata encodedSignature + ) private view returns (bool) { + // decode signature + ( + bytes calldata signature, + bytes32 appSeparator, + bytes32 contentsHash, + string calldata contentsDescr + ) = encodedSignature.decodeTypedDataSig(); + + ( + , + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + + ) = eip712Domain(); + + // Check that contentHash and separator are correct + // Rebuild nested hash + return + hash == appSeparator.toTypedDataHash(contentsHash) && + bytes(contentsDescr).length != 0 && + _rawSignatureValidation( + appSeparator.toTypedDataHash( + ERC7739Utils.typedDataSignStructHash( + contentsDescr, + contentsHash, + abi.encode(keccak256(bytes(name)), keccak256(bytes(version)), chainId, verifyingContract, salt) + ) + ), + signature + ); + } +} diff --git a/contracts/utils/cryptography/ERC7739Utils.sol b/contracts/utils/cryptography/ERC7739Utils.sol new file mode 100644 index 00000000000..e7b720ea0c9 --- /dev/null +++ b/contracts/utils/cryptography/ERC7739Utils.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Calldata} from "../Calldata.sol"; + +/** + * @dev Utilities to process https://ercs.ethereum.org/ERCS/erc-7739[ERC-7739] typed data signatures + * that are specific to an EIP-712 domain. + * + * This library provides methods to wrap, unwrap and operate over typed data signatures with a defensive + * rehashing mechanism that includes the application's xref:api:utils#EIP712-_domainSeparatorV4[EIP-712] + * and preserves readability of the signed content using an EIP-712 nested approach. + * + * A smart contract domain can validate a signature for a typed data structure in two ways: + * + * - As an application validating a typed data signature. See {typedDataSignStructHash}. + * - As a smart contract validating a raw message signature. See {personalSignStructHash}. + * + * NOTE: A provider for a smart contract wallet would need to return this signature as the + * result of a call to `personal_sign` or `eth_signTypedData`, and this may be unsupported by + * API clients that expect a return value of 129 bytes, or specifically the `r,s,v` parameters + * of an xref:api:utils#ECDSA[ECDSA] signature, as is for example specified for + * xref:api:utils#EIP712[EIP-712]. + */ +library ERC7739Utils { + /** + * @dev An EIP-712 type to represent "personal" signatures + * (i.e. mimic of `personal_sign` for smart contracts). + */ + bytes32 private constant PERSONAL_SIGN_TYPEHASH = keccak256("PersonalSign(bytes prefixed)"); + + /** + * @dev Nest a signature for a given EIP-712 type into a nested signature for the domain of the app. + * + * Counterpart of {decodeTypedDataSig} to extract the original signature and the nested components. + */ + function encodeTypedDataSig( + bytes memory signature, + bytes32 appSeparator, + bytes32 contentsHash, + string memory contentsDescr + ) internal pure returns (bytes memory) { + return + abi.encodePacked(signature, appSeparator, contentsHash, contentsDescr, uint16(bytes(contentsDescr).length)); + } + + /** + * @dev Parses a nested signature into its components. + * + * Constructed as follows: + * + * `signature ‖ APP_DOMAIN_SEPARATOR ‖ contentsHash ‖ contentsDescr ‖ uint16(contentsDescr.length)` + * + * - `signature` is the signature for the (ERC-7739) nested struct hash. This signature indirectly signs over the + * original "contents" hash (from the app) and the account's domain separator. + * - `APP_DOMAIN_SEPARATOR` is the EIP-712 {EIP712-_domainSeparatorV4} of the application smart contract that is + * requesting the signature verification (though ERC-1271). + * - `contentsHash` is the hash of the underlying data structure or message. + * - `contentsDescr` is a descriptor of the "contents" part of the the EIP-712 type of the nested signature. + * + * NOTE: This function returns empty if the input format is invalid instead of reverting. + * data instead. + */ + function decodeTypedDataSig( + bytes calldata encodedSignature + ) + internal + pure + returns (bytes calldata signature, bytes32 appSeparator, bytes32 contentsHash, string calldata contentsDescr) + { + unchecked { + uint256 sigLength = encodedSignature.length; + + // 66 bytes = contentsDescrLength (2 bytes) + contentsHash (32 bytes) + APP_DOMAIN_SEPARATOR (32 bytes). + if (sigLength < 66) return (Calldata.emptyBytes(), 0, 0, Calldata.emptyString()); + + uint256 contentsDescrEnd = sigLength - 2; // Last 2 bytes + uint256 contentsDescrLength = uint16(bytes2(encodedSignature[contentsDescrEnd:])); + + // Check for space for `contentsDescr` in addition to the 66 bytes documented above + if (sigLength < 66 + contentsDescrLength) return (Calldata.emptyBytes(), 0, 0, Calldata.emptyString()); + + uint256 contentsHashEnd = contentsDescrEnd - contentsDescrLength; + uint256 separatorEnd = contentsHashEnd - 32; + uint256 signatureEnd = separatorEnd - 32; + + signature = encodedSignature[:signatureEnd]; + appSeparator = bytes32(encodedSignature[signatureEnd:separatorEnd]); + contentsHash = bytes32(encodedSignature[separatorEnd:contentsHashEnd]); + contentsDescr = string(encodedSignature[contentsHashEnd:contentsDescrEnd]); + } + } + + /** + * @dev Nests an `ERC-191` digest into a `PersonalSign` EIP-712 struct, and returns the corresponding struct hash. + * This struct hash must be combined with a domain separator, using {MessageHashUtils-toTypedDataHash} before + * being verified/recovered. + * + * This is used to simulates the `personal_sign` RPC method in the context of smart contracts. + */ + function personalSignStructHash(bytes32 contents) internal pure returns (bytes32) { + return keccak256(abi.encode(PERSONAL_SIGN_TYPEHASH, contents)); + } + + /** + * @dev Nests an `EIP-712` hash (`contents`) into a `TypedDataSign` EIP-712 struct, and returns the corresponding + * struct hash. This struct hash must be combined with a domain separator, using {MessageHashUtils-toTypedDataHash} + * before being verified/recovered. + */ + function typedDataSignStructHash( + string calldata contentsName, + string calldata contentsType, + bytes32 contentsHash, + bytes memory domainBytes + ) internal pure returns (bytes32 result) { + return + bytes(contentsName).length == 0 + ? bytes32(0) + : keccak256( + abi.encodePacked(typedDataSignTypehash(contentsName, contentsType), contentsHash, domainBytes) + ); + } + + /** + * @dev Variant of {typedDataSignStructHash-string-string-bytes32-bytes} that takes a content descriptor + * and decodes the `contentsName` and `contentsType` out of it. + */ + function typedDataSignStructHash( + string calldata contentsDescr, + bytes32 contentsHash, + bytes memory domainBytes + ) internal pure returns (bytes32 result) { + (string calldata contentsName, string calldata contentsType) = decodeContentsDescr(contentsDescr); + + return typedDataSignStructHash(contentsName, contentsType, contentsHash, domainBytes); + } + + /** + * @dev Compute the EIP-712 typehash of the `TypedDataSign` structure for a given type (and typename). + */ + function typedDataSignTypehash( + string calldata contentsName, + string calldata contentsType + ) internal pure returns (bytes32) { + return + keccak256( + abi.encodePacked( + "TypedDataSign(", + contentsName, + " contents,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)", + contentsType + ) + ); + } + + /** + * @dev Parse the type name out of the ERC-7739 contents type description. Supports both the implicit and explicit + * modes. + * + * Following ERC-7739 specifications, a `contentsName` is considered invalid if it's empty or it contains + * any of the following bytes , )\x00 + * + * If the `contentsType` is invalid, this returns an empty string. Otherwise, the return string has non-zero + * length. + */ + function decodeContentsDescr( + string calldata contentsDescr + ) internal pure returns (string calldata contentsName, string calldata contentsType) { + bytes calldata buffer = bytes(contentsDescr); + if (buffer.length == 0) { + // pass through (fail) + } else if (buffer[buffer.length - 1] == bytes1(")")) { + // Implicit mode: read contentsName from the beginning, and keep the complete descr + for (uint256 i = 0; i < buffer.length; ++i) { + bytes1 current = buffer[i]; + if (current == bytes1("(")) { + // if name is empty - passthrough (fail) + if (i == 0) break; + // we found the end of the contentsName + return (string(buffer[:i]), contentsDescr); + } else if (_isForbiddenChar(current)) { + // we found an invalid character (forbidden) - passthrough (fail) + break; + } + } + } else { + // Explicit mode: read contentsName from the end, and remove it from the descr + for (uint256 i = buffer.length; i > 0; --i) { + bytes1 current = buffer[i - 1]; + if (current == bytes1(")")) { + // we found the end of the contentsName + return (string(buffer[i:]), string(buffer[:i])); + } else if (_isForbiddenChar(current)) { + // we found an invalid character (forbidden) - passthrough (fail) + break; + } + } + } + return (Calldata.emptyString(), Calldata.emptyString()); + } + + function _isForbiddenChar(bytes1 char) private pure returns (bool) { + return char == 0x00 || char == bytes1(" ") || char == bytes1(",") || char == bytes1("(") || char == bytes1(")"); + } +} diff --git a/contracts/utils/cryptography/ERC7913Utils.sol b/contracts/utils/cryptography/ERC7913Utils.sol new file mode 100644 index 00000000000..285d3895ff7 --- /dev/null +++ b/contracts/utils/cryptography/ERC7913Utils.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SignatureChecker} from "./SignatureChecker.sol"; +import {Bytes} from "../Bytes.sol"; +import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; + +/** + * @dev Library that provides common ERC-7913 utility functions. + * + * This library extends the functionality of xref:api:utils#SignatureChecker[SignatureChecker] + * to support signature verification for keys that do not have an Ethereum address of their own + * as with ERC-1271. + * + * See https://eips.ethereum.org/EIPS/eip-7913[ERC-7913]. + */ +library ERC7913Utils { + using Bytes for bytes; + + /** + * @dev Verifies a signature for a given signer and hash. + * + * The signer is a `bytes` object that is the concatenation of an address and optionally a key: + * `verifier || key`. A signer must be at least 20 bytes long. + * + * Verification is done as follows: + * - If `signer.length < 20`: verification fails + * - If `signer.length == 20`: verification is done using {SignatureChecker} + * - Otherwise: verification is done using {IERC7913SignatureVerifier} + */ + function isValidSignatureNow( + bytes memory signer, + bytes32 hash, + bytes memory signature + ) internal view returns (bool) { + if (signer.length < 20) { + return false; + } else if (signer.length == 20) { + return SignatureChecker.isValidSignatureNow(address(bytes20(signer)), hash, signature); + } else { + (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)); + } + } + + /** + * @dev Verifies multiple `signatures` for a given hash using a set of `signers`. + * + * The signers must be ordered by their `keccak256` hash 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. + */ + function areValidSignaturesNow( + bytes32 hash, + bytes[] memory signers, + bytes[] memory signatures + ) internal view returns (bool) { + bytes32 previousId = 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 = keccak256(signer); + if (previousId >= id || !isValidSignatureNow(signer, hash, signatures[i])) { + return false; + } + + previousId = id; + } + + return true; + } +} diff --git a/contracts/utils/cryptography/MultiSignerERC7913.sol b/contracts/utils/cryptography/MultiSignerERC7913.sol new file mode 100644 index 00000000000..2d348e32927 --- /dev/null +++ b/contracts/utils/cryptography/MultiSignerERC7913.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {AbstractSigner} from "./AbstractSigner.sol"; +import {ERC7913Utils} from "./ERC7913Utils.sol"; +import {EnumerableSetExtended} from "../structs/EnumerableSetExtended.sol"; +import {Calldata} from "../../utils/Calldata.sol"; +import {SafeCast} from "../../utils/math/SafeCast.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, MultiSignerERC7913, 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 MultiSignerERC7913 is AbstractSigner { + using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + using ERC7913Utils for *; + using SafeCast for uint256; + + EnumerableSetExtended.BytesSet private _signersSet; + uint128 private _threshold; + + /// @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 MultiSignerERC7913AlreadyExists(bytes signer); + + /// @dev The `signer` does not exist. + error MultiSignerERC7913NonexistentSigner(bytes signer); + + /// @dev The `signer` is less than 20 bytes long. + error MultiSignerERC7913InvalidSigner(bytes signer); + + /// @dev The `threshold` is unreachable given the number of `signers`. + error MultiSignerERC7913UnreachableThreshold(uint256 signers, uint256 threshold); + + /** + * @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 _signers().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; + } + + /// @dev Returns the set of authorized signers. + function _signers() internal view virtual returns (EnumerableSetExtended.BytesSet storage) { + return _signersSet; + } + + /** + * @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 { + uint256 newSignersLength = newSigners.length; + for (uint256 i = 0; i < newSignersLength; i++) { + bytes memory signer = newSigners[i]; + require(signer.length >= 20, MultiSignerERC7913InvalidSigner(signer)); + require(_signers().add(signer), MultiSignerERC7913AlreadyExists(signer)); + } + emit ERC7913SignersAdded(newSigners); + } + + /** + * @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 { + uint256 oldSignersLength = oldSigners.length; + for (uint256 i = 0; i < oldSignersLength; i++) { + bytes memory signer = oldSigners[i]; + require(_signers().remove(signer), MultiSignerERC7913NonexistentSigner(signer)); + } + _validateReachableThreshold(); + emit ERC7913SignersRemoved(oldSigners); + } + + /** + * @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. + * + * 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(); + require( + totalSigners >= currentThreshold, + MultiSignerERC7913UnreachableThreshold(totalSigners, currentThreshold) + ); + } + + /** + * @dev Decodes, validates the signature and checks the signers are authorized. + * See {_validateSignatures} 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); + * ``` + * + * Requirements: + * + * * The `signature` must be encoded as `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 signingSigners, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); + if (signingSigners.length != signatures.length) return false; + return _validateThreshold(signingSigners) && _validateSignatures(hash, signingSigners, signatures); + } + + /** + * @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 `keccak256` hash + * to avoid duplication when iterating through the signers (i.e. `keccak256(signer1) < keccak256(signer2)`). + * The function will return false if the signers are not ordered. + * + * Requirements: + * + * * The `signatures` arrays must be at least as large as the `signingSigners` arrays. Panics otherwise. + */ + function _validateSignatures( + bytes32 hash, + bytes[] memory signingSigners, + bytes[] memory signatures + ) internal view virtual returns (bool valid) { + uint256 signersLength = signingSigners.length; + for (uint256 i = 0; i < signersLength; i++) { + if (!isSigner(signingSigners[i])) { + return false; + } + } + return hash.areValidSignaturesNow(signingSigners, signatures); + } + + /** + * @dev Validates that the number of signers meets the {threshold} requirement. + * Assumes the signers were already validated. See {_validateSignatures} for more details. + */ + function _validateThreshold(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 new file mode 100644 index 00000000000..5e7cb2831c0 --- /dev/null +++ b/contracts/utils/cryptography/MultiSignerERC7913Weighted.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {Math} from "../math/Math.sol"; +import {SafeCast} from "../math/SafeCast.sol"; +import {MultiSignerERC7913} from "./MultiSignerERC7913.sol"; +import {EnumerableSetExtended} from "../../utils/structs/EnumerableSetExtended.sol"; + +/** + * @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 + * than others, allowing for weighted voting or prioritized authorization. + * + * Example of usage: + * + * ```solidity + * contract MyWeightedMultiSignerAccount is Account, MultiSignerERC7913Weighted, 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 MultiSignerERC7913Weighted is MultiSignerERC7913 { + using EnumerableSetExtended for EnumerableSetExtended.BytesSet; + using SafeCast for uint256; + + // Invariant: sum(weights) >= threshold + uint128 private _totalWeight; + + // Mapping from signer to weight + mapping(bytes signer => uint256) private _weights; + + /// @dev Emitted when a signer's weight is changed. + event ERC7913SignerWeightChanged(bytes indexed signer, uint256 weight); + + /// @dev Thrown when a signer's weight is invalid. + error MultiSignerERC7913WeightedInvalidWeight(bytes signer, uint256 weight); + + /// @dev Thrown when the threshold is unreachable. + 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) { + return Math.ternary(isSigner(signer), _signerWeight(signer), 0); + } + + /// @dev Gets the total weight of all signers. + function totalWeight() public view virtual returns (uint256) { + return _totalWeight; // Doesn't need Math.max because it's incremented by the default 1 in `_addSigners` + } + + /** + * @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[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 {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()); + uint256 oldWeight = _weightSigners(signers); + uint256 signersLength = signers.length; + + for (uint256 i = 0; i < signersLength; i++) { + bytes memory signer = signers[i]; + uint256 newWeight = newWeights[i]; + require(isSigner(signer), MultiSignerERC7913NonexistentSigner(signer)); + require(newWeight > 0, MultiSignerERC7913WeightedInvalidWeight(signer, newWeight)); + } + + _unsafeSetSignerWeights(signers, newWeights); + _totalWeight = (_totalWeight - oldWeight + _weightSigners(signers)).toUint128(); + _validateReachableThreshold(); + } + + /// @inheritdoc MultiSignerERC7913 + function _addSigners(bytes[] memory newSigners) internal virtual override { + super._addSigners(newSigners); + _totalWeight += newSigners.length.toUint128(); // Each new signer has a default weight of 1 + } + + /** + * @dev See {MultiSignerERC7913-_removeSigners}. + * + * Emits {ERC7913SignerWeightChanged} for each removed signer. + */ + function _removeSigners(bytes[] memory oldSigners) internal virtual override { + uint256 removedWeight = _weightSigners(oldSigners); + unchecked { + // Can't overflow. Invariant: sum(weights) >= threshold + _totalWeight -= removedWeight.toUint128(); + } + // Clean up weights for removed signers + _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 + * depending on the linearization order. + */ + function _validateReachableThreshold() internal view virtual override { + uint256 weight = totalWeight(); + uint256 currentThreshold = threshold(); + require(weight >= currentThreshold, MultiSignerERC7913UnreachableThreshold(weight, currentThreshold)); + } + + /** + * @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 + * 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) { + return _weightSigners(signers) >= threshold(); + } + + /// @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 weight = 0; + uint256 signersLength = signers.length; + for (uint256 i = 0; i < signersLength; i++) { + weight += signerWeight(signers[i]); + } + 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. + * + * Emits {ERC7913SignerWeightChanged} for each signer. + */ + function _unsafeSetSignerWeights(bytes[] memory signers, uint256[] memory newWeights) private { + uint256 signersLength = signers.length; + for (uint256 i = 0; i < signersLength; i++) { + _weights[signers[i]] = newWeights[i]; + emit ERC7913SignerWeightChanged(signers[i], newWeights[i]); + } + } +} diff --git a/contracts/utils/cryptography/SignerECDSA.sol b/contracts/utils/cryptography/SignerECDSA.sol new file mode 100644 index 00000000000..ca6442c6277 --- /dev/null +++ b/contracts/utils/cryptography/SignerECDSA.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ECDSA} from "../cryptography/ECDSA.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; + +/** + * @dev Implementation of {AbstractSigner} using xref:api:utils#ECDSA[ECDSA] signatures. + * + * For {Account} usage, a {_setSigner} function is provided to set the {signer} address. + * Doing so is easier for a factory, who is likely to use initializable clones of this contract. + * + * Example of usage: + * + * ```solidity + * contract MyAccountECDSA is Account, SignerECDSA, Initializable { + * function initialize(address signerAddr) public initializer { + * _setSigner(signerAddr); + * } + * } + * ``` + * + * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ +abstract contract SignerECDSA is AbstractSigner { + address private _signer; + + /** + * @dev Sets the signer with the address of the native signer. This function should be called during construction + * or through an initializer. + */ + function _setSigner(address signerAddr) internal { + _signer = signerAddr; + } + + /// @dev Return the signer's address. + function signer() public view virtual returns (address) { + return _signer; + } + + /// @inheritdoc AbstractSigner + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature); + return signer() == recovered && err == ECDSA.RecoverError.NoError; + } +} diff --git a/contracts/utils/cryptography/SignerERC7702.sol b/contracts/utils/cryptography/SignerERC7702.sol new file mode 100644 index 00000000000..baa2f2ac670 --- /dev/null +++ b/contracts/utils/cryptography/SignerERC7702.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ECDSA} from "./ECDSA.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; + +/** + * @dev Implementation of {AbstractSigner} for implementation for an EOA. Useful for ERC-7702 accounts. + * + * @custom:stateless + */ +abstract contract SignerERC7702 is AbstractSigner { + /** + * @dev Validates the signature using the EOA's address (i.e. `address(this)`). + */ + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature); + return address(this) == recovered && err == ECDSA.RecoverError.NoError; + } +} diff --git a/contracts/utils/cryptography/SignerERC7913.sol b/contracts/utils/cryptography/SignerERC7913.sol new file mode 100644 index 00000000000..ae05d618677 --- /dev/null +++ b/contracts/utils/cryptography/SignerERC7913.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {AbstractSigner} from "./AbstractSigner.sol"; +import {ERC7913Utils} from "./ERC7913Utils.sol"; + +/** + * @dev Implementation of {AbstractSigner} using + * https://eips.ethereum.org/EIPS/eip-7913[ERC-7913] signature verification. + * + * For {Account} usage, a {_setSigner} function is provided to set the ERC-7913 formatted {signer}. + * Doing so is easier for a factory, who is likely to use initializable clones of this contract. + * + * The signer is a `bytes` object that concatenates a verifier address and a key: `verifier || key`. + * + * Example of usage: + * + * ```solidity + * contract MyAccountERC7913 is Account, SignerERC7913, Initializable { + * function initialize(bytes memory signer_) public initializer { + * _setSigner(signer_); + * } + * } + * ``` + * + * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ + +abstract contract SignerERC7913 is AbstractSigner { + bytes private _signer; + + /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). + function signer() public view virtual returns (bytes memory) { + return _signer; + } + + /// @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 + ) internal view virtual override returns (bool) { + return ERC7913Utils.isValidSignatureNow(signer(), hash, signature); + } +} diff --git a/contracts/utils/cryptography/SignerP256.sol b/contracts/utils/cryptography/SignerP256.sol new file mode 100644 index 00000000000..b28e90ad857 --- /dev/null +++ b/contracts/utils/cryptography/SignerP256.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {P256} from "./P256.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; + +/** + * @dev Implementation of {AbstractSigner} using xref:api:utils#P256[P256] signatures. + * + * For {Account} usage, a {_setSigner} function is provided to set the {signer} public key. + * Doing so is easier for a factory, who is likely to use initializable clones of this contract. + * + * Example of usage: + * + * ```solidity + * contract MyAccountP256 is Account, SignerP256, Initializable { + * function initialize(bytes32 qx, bytes32 qy) public initializer { + * _setSigner(qx, qy); + * } + * } + * ``` + * + * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ +abstract contract SignerP256 is AbstractSigner { + bytes32 private _qx; + bytes32 private _qy; + + error SignerP256InvalidPublicKey(bytes32 qx, bytes32 qy); + + /** + * @dev Sets the signer with a P256 public key. This function should be called during construction + * or through an initializer. + */ + function _setSigner(bytes32 qx, bytes32 qy) internal { + if (!P256.isValidPublicKey(qx, qy)) revert SignerP256InvalidPublicKey(qx, qy); + _qx = qx; + _qy = qy; + } + + /// @dev Return the signer's P256 public key. + function signer() public view virtual returns (bytes32 qx, bytes32 qy) { + return (_qx, _qy); + } + + /// @inheritdoc AbstractSigner + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + if (signature.length < 0x40) return false; + bytes32 r = bytes32(signature[0x00:0x20]); + bytes32 s = bytes32(signature[0x20:0x40]); + (bytes32 qx, bytes32 qy) = signer(); + return P256.verify(hash, r, s, qx, qy); + } +} diff --git a/contracts/utils/cryptography/SignerRSA.sol b/contracts/utils/cryptography/SignerRSA.sol new file mode 100644 index 00000000000..481bb182518 --- /dev/null +++ b/contracts/utils/cryptography/SignerRSA.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {RSA} from "./RSA.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; + +/** + * @dev Implementation of {AbstractSigner} using xref:api:utils#RSA[RSA] signatures. + * + * For {Account} usage, a {_setSigner} function is provided to set the {signer} public key. + * Doing so is easier for a factory, who is likely to use initializable clones of this contract. + * + * Example of usage: + * + * ```solidity + * contract MyAccountRSA is Account, SignerRSA, Initializable { + * function initialize(bytes memory e, bytes memory n) public initializer { + * _setSigner(e, n); + * } + * } + * ``` + * + * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ +abstract contract SignerRSA is AbstractSigner { + bytes private _e; + bytes private _n; + + /** + * @dev Sets the signer with a RSA public key. This function should be called during construction + * or through an initializer. + */ + function _setSigner(bytes memory e, bytes memory n) internal { + _e = e; + _n = n; + } + + /// @dev Return the signer's RSA public key. + function signer() public view virtual returns (bytes memory e, bytes memory n) { + return (_e, _n); + } + + /** + * @dev See {AbstractSigner-_rawSignatureValidation}. Verifies a PKCSv1.5 signature by calling + * xref:api:utils.adoc#RSA-pkcs1Sha256-bytes-bytes-bytes-bytes-[RSA.pkcs1Sha256]. + * + * IMPORTANT: Following the RSASSA-PKCS1-V1_5-VERIFY procedure outlined in RFC8017 (section 8.2.2), the + * provided `hash` is used as the `M` (message) and rehashed using SHA256 according to EMSA-PKCS1-v1_5 + * encoding as per section 9.2 (step 1) of the RFC. + */ + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (bytes memory e, bytes memory n) = signer(); + return RSA.pkcs1Sha256(abi.encodePacked(hash), signature, e, n); + } +} diff --git a/contracts/utils/structs/EnumerableMapExtended.sol b/contracts/utils/structs/EnumerableMapExtended.sol new file mode 100644 index 00000000000..91b912e6d72 --- /dev/null +++ b/contracts/utils/structs/EnumerableMapExtended.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MIT +// This file was procedurally generated from scripts/generate/templates/EnumerableMapExtended.js. + +pragma solidity ^0.8.20; + +import {EnumerableSet} from "./EnumerableSet.sol"; +import {EnumerableSetExtended} from "./EnumerableSetExtended.sol"; + +/** + * @dev Library for managing an enumerable variant of Solidity's + * https://solidity.readthedocs.io/en/latest/types.html#mapping-types[`mapping`] + * type for non-value types as keys. + * + * Maps have the following properties: + * + * - Entries are added, removed, and checked for existence in constant time + * (O(1)). + * - Entries are enumerated in O(n). No guarantees are made on the ordering. + * - Map can be cleared (all entries removed) in O(n). + * + * ```solidity + * contract Example { + * // Add the library methods + * using EnumerableMapExtended for EnumerableMapExtended.BytesToUintMap; + * + * // Declare a set state variable + * EnumerableMapExtended.BytesToUintMap private myMap; + * } + * ``` + * + * The following map types are supported: + * + * - `bytes -> uint256` (`BytesToUintMap`) + * - `string -> string` (`StringToStringMap`) + * + * [WARNING] + * ==== + * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure + * unusable. + * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info. + * + * In order to clean an EnumerableMap, you can either remove all elements one by one or create a fresh instance using an + * array of EnumerableMap. + * ==== + * + * NOTE: Extensions of {EnumerableMap} + */ +library EnumerableMapExtended { + using EnumerableSet for *; + using EnumerableSetExtended for *; + + /** + * @dev Query for a nonexistent map key. + */ + error EnumerableMapNonexistentBytesKey(bytes key); + + struct BytesToUintMap { + // Storage of keys + EnumerableSetExtended.BytesSet _keys; + mapping(bytes key => uint256) _values; + } + + /** + * @dev Adds a key-value pair to a map, or updates the value for an existing + * key. O(1). + * + * Returns true if the key was added to the map, that is if it was not + * already present. + */ + function set(BytesToUintMap storage map, bytes memory key, uint256 value) internal returns (bool) { + map._values[key] = value; + return map._keys.add(key); + } + + /** + * @dev Removes a key-value pair from a map. O(1). + * + * Returns true if the key was removed from the map, that is if it was present. + */ + function remove(BytesToUintMap storage map, bytes memory key) internal returns (bool) { + delete map._values[key]; + return map._keys.remove(key); + } + + /** + * @dev Removes all the entries from a map. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the map grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(BytesToUintMap storage map) internal { + uint256 len = length(map); + for (uint256 i = 0; i < len; ++i) { + delete map._values[map._keys.at(i)]; + } + map._keys.clear(); + } + + /** + * @dev Returns true if the key is in the map. O(1). + */ + function contains(BytesToUintMap storage map, bytes memory key) internal view returns (bool) { + return map._keys.contains(key); + } + + /** + * @dev Returns the number of key-value pairs in the map. O(1). + */ + function length(BytesToUintMap storage map) internal view returns (uint256) { + return map._keys.length(); + } + + /** + * @dev Returns the key-value pair stored at position `index` in the map. O(1). + * + * Note that there are no guarantees on the ordering of entries inside the + * array, and it may change when more entries are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(BytesToUintMap storage map, uint256 index) internal view returns (bytes memory key, uint256 value) { + key = map._keys.at(index); + value = map._values[key]; + } + + /** + * @dev Tries to returns the value associated with `key`. O(1). + * Does not revert if `key` is not in the map. + */ + function tryGet(BytesToUintMap storage map, bytes memory key) internal view returns (bool exists, uint256 value) { + value = map._values[key]; + exists = value != uint256(0) || contains(map, key); + } + + /** + * @dev Returns the value associated with `key`. O(1). + * + * Requirements: + * + * - `key` must be in the map. + */ + function get(BytesToUintMap storage map, bytes memory key) internal view returns (uint256 value) { + bool exists; + (exists, value) = tryGet(map, key); + if (!exists) { + revert EnumerableMapNonexistentBytesKey(key); + } + } + + /** + * @dev Return the an array containing all the keys + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the map grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function keys(BytesToUintMap storage map) internal view returns (bytes[] memory) { + return map._keys.values(); + } + + /** + * @dev Query for a nonexistent map key. + */ + error EnumerableMapNonexistentStringKey(string key); + + struct StringToStringMap { + // Storage of keys + EnumerableSetExtended.StringSet _keys; + mapping(string key => string) _values; + } + + /** + * @dev Adds a key-value pair to a map, or updates the value for an existing + * key. O(1). + * + * Returns true if the key was added to the map, that is if it was not + * already present. + */ + function set(StringToStringMap storage map, string memory key, string memory value) internal returns (bool) { + map._values[key] = value; + return map._keys.add(key); + } + + /** + * @dev Removes a key-value pair from a map. O(1). + * + * Returns true if the key was removed from the map, that is if it was present. + */ + function remove(StringToStringMap storage map, string memory key) internal returns (bool) { + delete map._values[key]; + return map._keys.remove(key); + } + + /** + * @dev Removes all the entries from a map. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the map grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(StringToStringMap storage map) internal { + uint256 len = length(map); + for (uint256 i = 0; i < len; ++i) { + delete map._values[map._keys.at(i)]; + } + map._keys.clear(); + } + + /** + * @dev Returns true if the key is in the map. O(1). + */ + function contains(StringToStringMap storage map, string memory key) internal view returns (bool) { + return map._keys.contains(key); + } + + /** + * @dev Returns the number of key-value pairs in the map. O(1). + */ + function length(StringToStringMap storage map) internal view returns (uint256) { + return map._keys.length(); + } + + /** + * @dev Returns the key-value pair stored at position `index` in the map. O(1). + * + * Note that there are no guarantees on the ordering of entries inside the + * array, and it may change when more entries are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at( + StringToStringMap storage map, + uint256 index + ) internal view returns (string memory key, string memory value) { + key = map._keys.at(index); + value = map._values[key]; + } + + /** + * @dev Tries to returns the value associated with `key`. O(1). + * Does not revert if `key` is not in the map. + */ + function tryGet( + StringToStringMap storage map, + string memory key + ) internal view returns (bool exists, string memory value) { + value = map._values[key]; + exists = bytes(value).length != 0 || contains(map, key); + } + + /** + * @dev Returns the value associated with `key`. O(1). + * + * Requirements: + * + * - `key` must be in the map. + */ + function get(StringToStringMap storage map, string memory key) internal view returns (string memory value) { + bool exists; + (exists, value) = tryGet(map, key); + if (!exists) { + revert EnumerableMapNonexistentStringKey(key); + } + } + + /** + * @dev Return the an array containing all the keys + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the map grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function keys(StringToStringMap storage map) internal view returns (string[] memory) { + return map._keys.values(); + } +} diff --git a/contracts/utils/structs/EnumerableSetExtended.sol b/contracts/utils/structs/EnumerableSetExtended.sol new file mode 100644 index 00000000000..a5ba388a74f --- /dev/null +++ b/contracts/utils/structs/EnumerableSetExtended.sol @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: MIT +// This file was procedurally generated from scripts/generate/templates/EnumerableSetExtended.js. + +pragma solidity ^0.8.20; + +import {Hashes} from "../cryptography/Hashes.sol"; + +/** + * @dev Library for managing + * https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets] of non-value + * types. + * + * Sets have the following properties: + * + * - Elements are added, removed, and checked for existence in constant time + * (O(1)). + * - Elements are enumerated in O(n). No guarantees are made on the ordering. + * - Set can be cleared (all elements removed) in O(n). + * + * ```solidity + * contract Example { + * // Add the library methods + * using EnumerableSetExtended for EnumerableSetExtended.StringSet; + * + * // Declare a set state variable + * EnumerableSetExtended.StringSet private mySet; + * } + * ``` + * + * Sets of type `string` (`StringSet`), `bytes` (`BytesSet`) and + * `bytes32[2]` (`Bytes32x2Set`) are supported. + * + * [WARNING] + * ==== + * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure + * unusable. + * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info. + * + * In order to clean an EnumerableSet, you can either remove all elements one by one or create a fresh instance using an + * array of EnumerableSet. + * ==== + * + * NOTE: This is an extension of {EnumerableSet}. + */ +library EnumerableSetExtended { + struct StringSet { + // Storage of set values + string[] _values; + // Position is the index of the value in the `values` array plus 1. + // Position 0 is used to mean a value is not in the set. + mapping(string value => uint256) _positions; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(StringSet storage self, string memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[value] = self._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(StringSet storage self, string memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + uint256 position = self._positions[value]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + string memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[lastValue] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[value]; + + return true; + } else { + return false; + } + } + + /** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(StringSet storage set) internal { + uint256 len = length(set); + for (uint256 i = 0; i < len; ++i) { + delete set._positions[set._values[i]]; + } + // Replace when these are available in Arrays.sol + string[] storage array = set._values; + assembly ("memory-safe") { + sstore(array.slot, 0) + } + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(StringSet storage self, string memory value) internal view returns (bool) { + return self._positions[value] != 0; + } + + /** + * @dev Returns the number of values on the set. O(1). + */ + function length(StringSet storage self) internal view returns (uint256) { + return self._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(StringSet storage self, uint256 index) internal view returns (string memory) { + return self._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(StringSet storage self) internal view returns (string[] memory) { + return self._values; + } + + struct BytesSet { + // Storage of set values + bytes[] _values; + // Position is the index of the value in the `values` array plus 1. + // Position 0 is used to mean a value is not in the set. + mapping(bytes value => uint256) _positions; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(BytesSet storage self, bytes memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[value] = self._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(BytesSet storage self, bytes memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + uint256 position = self._positions[value]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + bytes memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[lastValue] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[value]; + + return true; + } else { + return false; + } + } + + /** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(BytesSet storage set) internal { + uint256 len = length(set); + for (uint256 i = 0; i < len; ++i) { + delete set._positions[set._values[i]]; + } + // Replace when these are available in Arrays.sol + bytes[] storage array = set._values; + assembly ("memory-safe") { + sstore(array.slot, 0) + } + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(BytesSet storage self, bytes memory value) internal view returns (bool) { + return self._positions[value] != 0; + } + + /** + * @dev Returns the number of values on the set. O(1). + */ + function length(BytesSet storage self) internal view returns (uint256) { + return self._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(BytesSet storage self, uint256 index) internal view returns (bytes memory) { + return self._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(BytesSet storage self) internal view returns (bytes[] memory) { + return self._values; + } + + struct Bytes32x2Set { + // Storage of set values + bytes32[2][] _values; + // Position is the index of the value in the `values` array plus 1. + // Position 0 is used to mean a value is not in the set. + mapping(bytes32 valueHash => uint256) _positions; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(Bytes32x2Set storage self, bytes32[2] memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[_hash(value)] = self._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(Bytes32x2Set storage self, bytes32[2] memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + bytes32 valueHash = _hash(value); + uint256 position = self._positions[valueHash]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + bytes32[2] memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[_hash(lastValue)] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[valueHash]; + + return true; + } else { + return false; + } + } + + /** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ + function clear(Bytes32x2Set storage self) internal { + bytes32[2][] storage v = self._values; + + uint256 len = length(self); + for (uint256 i = 0; i < len; ++i) { + delete self._positions[_hash(v[i])]; + } + assembly ("memory-safe") { + sstore(v.slot, 0) + } + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(Bytes32x2Set storage self, bytes32[2] memory value) internal view returns (bool) { + return self._positions[_hash(value)] != 0; + } + + /** + * @dev Returns the number of values on the set. O(1). + */ + function length(Bytes32x2Set storage self) internal view returns (uint256) { + return self._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(Bytes32x2Set storage self, uint256 index) internal view returns (bytes32[2] memory) { + return self._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(Bytes32x2Set storage self) internal view returns (bytes32[2][] memory) { + return self._values; + } + + function _hash(bytes32[2] memory value) private pure returns (bytes32) { + return Hashes.efficientKeccak256(value[0], value[1]); + } +} diff --git a/docs/modules/ROOT/pages/utilities.adoc b/docs/modules/ROOT/pages/utilities.adoc index 04cb2ee43a5..64b8029624b 100644 --- a/docs/modules/ROOT/pages/utilities.adoc +++ b/docs/modules/ROOT/pages/utilities.adoc @@ -97,6 +97,31 @@ function _verify( IMPORTANT: Always use keys of at least 2048 bits. Additionally, be aware that PKCS#1 v1.5 allows for replayability due to the possibility of arbitrary optional parameters. To prevent replay attacks, consider including an onchain nonce or unique identifier in the message. +=== ERC-7913 Signature Verifiers + +ERC-7913 extends the concept of signature verification to support keys that don't have their own Ethereum address. This is particularly useful for integrating non-Ethereum cryptographic curves, hardware devices, or other identity systems into smart accounts. + +The standard defines a verifier interface that can be implemented to support different types of keys. A signer is represented as a `bytes` object that concatenates a verifier address and a key: `verifier || key`. + +xref:api:utils.adoc#ERC7913Utils[`ERC7913Utils`] provides functions for verifying signatures using ERC-7913 compatible verifiers: + +[source,solidity] +---- +using ERC7913Utils for bytes; + +function _verify(bytes memory signer, bytes32 hash, bytes memory signature) internal view returns (bool) { + return signer.isValidSignatureNow(hash, signature); +} +---- + +The verification process works as follows: + +* If `signer.length < 20`: verification fails +* If `signer.length == 20`: verification is done using xref:api:utils#SignatureChecker[SignatureChecker] +* Otherwise: verification is done using an ERC-7913 verifier. + +This allows for backward compatibility with EOAs and ERC-1271 contracts while supporting new types of keys. + === Verifying Merkle Proofs Developers can build a Merkle Tree off-chain, which allows for verifying that an element (leaf) is part of a set by using a Merkle Proof. This technique is widely used for creating whitelists (e.g., for airdrops) and other advanced use cases. diff --git a/foundry.toml b/foundry.toml index 7a2e8a60942..ea8b1fadd88 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,5 +1,5 @@ [profile.default] -solc_version = '0.8.24' +solc_version = '0.8.27' evm_version = 'prague' optimizer = true optimizer-runs = 200 diff --git a/hardhat.config.js b/hardhat.config.js index 30c19ca6d5b..17ebf45eeb7 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -18,7 +18,7 @@ const { argv } = require('yargs/yargs')() compiler: { alias: 'compileVersion', type: 'string', - default: '0.8.24', + default: '0.8.27', }, src: { alias: 'source', @@ -38,7 +38,7 @@ const { argv } = require('yargs/yargs')() evm: { alias: 'evmVersion', type: 'string', - default: 'cancun', + default: 'prague', }, // Extra modules coverage: { diff --git a/package-lock.json b/package-lock.json index a82155c7608..e1b7018388f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "chai": "^4.2.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", - "ethers": "^6.13.4", + "ethers": "6.13.6-beta.1", "glob": "^11.0.0", "globals": "^16.0.0", "graphlib": "^2.1.8", @@ -4483,9 +4483,9 @@ } }, "node_modules/ethers": { - "version": "6.13.7", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.7.tgz", - "integrity": "sha512-qbaJ0uIrjh+huP1Lad2f2QtzW5dcqSVjIzVH6yWB4dKoMuj2WqYz5aMeeQTCNpAKgTJBM5J9vcc2cYJ23UAimQ==", + "version": "6.13.6-beta.1", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.6-beta.1.tgz", + "integrity": "sha512-sJZklf+m7QrlzYnOFbR0qHPqgYHeevbY98VIhzvnSdzhJVN/nNV/skKc/4wjyxbWRhK9t7r6ENcwUwLPjfxTLw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index d2f7dec6c6d..64ee144be16 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "chai": "^4.2.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", - "ethers": "^6.13.4", + "ethers": "6.13.6-beta.1", "glob": "^11.0.0", "globals": "^16.0.0", "graphlib": "^2.1.8", diff --git a/scripts/generate/run.js b/scripts/generate/run.js index 6779c93f44b..68516fa135b 100755 --- a/scripts/generate/run.js +++ b/scripts/generate/run.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -// const cp = require('child_process'); +const cp = require('child_process'); const fs = require('fs'); const path = require('path'); const format = require('./format-lines'); @@ -27,7 +27,7 @@ function generateFromTemplate(file, template, outputPrefix = '') { ); fs.writeFileSync(output, content); - // cp.execFileSync('prettier', ['--write', output]); + cp.execFileSync('prettier', ['--write', output]); } // Contracts @@ -44,6 +44,8 @@ for (const [file, template] of Object.entries({ 'utils/Packing.sol': './templates/Packing.js', 'mocks/StorageSlotMock.sol': './templates/StorageSlotMock.js', 'mocks/TransientSlotMock.sol': './templates/TransientSlotMock.js', + 'utils/structs/EnumerableSetExtended.sol': './templates/EnumerableSetExtended.js', + 'utils/structs/EnumerableMapExtended.sol': './templates/EnumerableMapExtended.js', })) { generateFromTemplate(file, template, './contracts/'); } diff --git a/scripts/generate/templates/Enumerable.opts.js b/scripts/generate/templates/Enumerable.opts.js new file mode 100644 index 00000000000..cad0f4d7908 --- /dev/null +++ b/scripts/generate/templates/Enumerable.opts.js @@ -0,0 +1,64 @@ +const { capitalize, mapValues } = require('../../helpers'); + +const mapType = str => (str == 'uint256' ? 'Uint' : capitalize(str)); + +const formatSetType = type => ({ name: `${mapType(type)}Set`, type }); + +const SET_TYPES = ['bytes32', 'address', 'uint256'].map(formatSetType); + +const formatMapType = (keyType, valueType) => ({ + name: `${mapType(keyType)}To${mapType(valueType)}Map`, + keyType, + valueType, +}); + +const MAP_TYPES = ['uint256', 'address', 'bytes32'] + .flatMap((key, _, array) => array.map(value => [key, value])) + .slice(0, -1) // remove bytes32 → byte32 (last one) that is already defined + .map(args => formatMapType(...args)); + +const extendedTypeDescr = ({ type, size = 0, memory = false }) => { + memory |= size > 0; + + const name = [type == 'uint256' ? 'Uint' : capitalize(type), size].filter(Boolean).join('x'); + const base = size ? type : undefined; + const typeFull = size ? `${type}[${size}]` : type; + const typeLoc = memory ? `${typeFull} memory` : typeFull; + return { name, type: typeFull, typeLoc, base, size, memory }; +}; + +const toExtendedSetTypeDescr = value => ({ name: value.name + 'Set', value }); + +const toExtendedMapTypeDescr = ({ key, value }) => ({ + name: `${key.name}To${value.name}Map`, + keySet: toExtendedSetTypeDescr(key), + key, + value, +}); + +const EXTENDED_SET_TYPES = [ + { type: 'bytes32', size: 2 }, + { type: 'string', memory: true }, + { type: 'bytes', memory: true }, +] + .map(extendedTypeDescr) + .map(toExtendedSetTypeDescr); + +const EXTENDED_MAP_TYPES = [ + { key: { type: 'bytes', memory: true }, value: { type: 'uint256' } }, + { key: { type: 'string', memory: true }, value: { type: 'string', memory: true } }, +] + .map(entry => mapValues(entry, extendedTypeDescr)) + .map(toExtendedMapTypeDescr); + +module.exports = { + SET_TYPES, + MAP_TYPES, + EXTENDED_SET_TYPES, + EXTENDED_MAP_TYPES, + formatSetType, + formatMapType, + extendedTypeDescr, + toExtendedSetTypeDescr, + toExtendedMapTypeDescr, +}; diff --git a/scripts/generate/templates/EnumerableMap.js b/scripts/generate/templates/EnumerableMap.js index 284e5ac0281..8879c7a4b11 100644 --- a/scripts/generate/templates/EnumerableMap.js +++ b/scripts/generate/templates/EnumerableMap.js @@ -1,6 +1,6 @@ const format = require('../format-lines'); const { fromBytes32, toBytes32 } = require('./conversion'); -const { TYPES } = require('./EnumerableMap.opts'); +const { MAP_TYPES } = require('./Enumerable.opts'); const header = `\ pragma solidity ^0.8.20; @@ -290,7 +290,7 @@ module.exports = format( 'using EnumerableSet for EnumerableSet.Bytes32Set;', '', defaultMap, - TYPES.map(details => customMap(details)), + MAP_TYPES.map(details => customMap(details)), ), ).trimEnd(), '}', diff --git a/scripts/generate/templates/EnumerableMap.opts.js b/scripts/generate/templates/EnumerableMap.opts.js deleted file mode 100644 index d26ab05b2ac..00000000000 --- a/scripts/generate/templates/EnumerableMap.opts.js +++ /dev/null @@ -1,19 +0,0 @@ -const { capitalize } = require('../../helpers'); - -const mapType = str => (str == 'uint256' ? 'Uint' : capitalize(str)); - -const formatType = (keyType, valueType) => ({ - name: `${mapType(keyType)}To${mapType(valueType)}Map`, - keyType, - valueType, -}); - -const TYPES = ['uint256', 'address', 'bytes32'] - .flatMap((key, _, array) => array.map(value => [key, value])) - .slice(0, -1) // remove bytes32 → byte32 (last one) that is already defined - .map(args => formatType(...args)); - -module.exports = { - TYPES, - formatType, -}; diff --git a/scripts/generate/templates/EnumerableMapExtended.js b/scripts/generate/templates/EnumerableMapExtended.js new file mode 100644 index 00000000000..8baf4a752da --- /dev/null +++ b/scripts/generate/templates/EnumerableMapExtended.js @@ -0,0 +1,173 @@ +const format = require('../format-lines'); +const { EXTENDED_SET_TYPES, EXTENDED_MAP_TYPES } = require('./Enumerable.opts'); + +const header = `\ +pragma solidity ^0.8.20; + +import {EnumerableSet} from "./EnumerableSet.sol"; +import {EnumerableSetExtended} from "./EnumerableSetExtended.sol"; + +/** + * @dev Library for managing an enumerable variant of Solidity's + * https://solidity.readthedocs.io/en/latest/types.html#mapping-types[\`mapping\`] + * type for non-value types as keys. + * + * Maps have the following properties: + * + * - Entries are added, removed, and checked for existence in constant time + * (O(1)). + * - Entries are enumerated in O(n). No guarantees are made on the ordering. + * - Map can be cleared (all entries removed) in O(n). + * + * \`\`\`solidity + * contract Example { + * // Add the library methods + * using EnumerableMapExtended for EnumerableMapExtended.BytesToUintMap; + * + * // Declare a set state variable + * EnumerableMapExtended.BytesToUintMap private myMap; + * } + * \`\`\` + * + * The following map types are supported: + * + * - \`bytes -> uint256\` (\`BytesToUintMap\`) + * - \`string -> string\` (\`StringToStringMap\`) + * + * [WARNING] + * ==== + * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure + * unusable. + * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info. + * + * In order to clean an EnumerableMap, you can either remove all elements one by one or create a fresh instance using an + * array of EnumerableMap. + * ==== + * + * NOTE: Extensions of {EnumerableMap} + */ +`; + +const map = ({ name, keySet, key, value }) => `\ +/** + * @dev Query for a nonexistent map key. + */ +error EnumerableMapNonexistent${key.name}Key(${key.type} key); + +struct ${name} { + // Storage of keys + ${EXTENDED_SET_TYPES.some(el => el.name == keySet.name) ? 'EnumerableSetExtended' : 'EnumerableSet'}.${keySet.name} _keys; + mapping(${key.type} key => ${value.type}) _values; +} + +/** + * @dev Adds a key-value pair to a map, or updates the value for an existing + * key. O(1). + * + * Returns true if the key was added to the map, that is if it was not + * already present. + */ +function set(${name} storage map, ${key.typeLoc} key, ${value.typeLoc} value) internal returns (bool) { + map._values[key] = value; + return map._keys.add(key); +} + +/** + * @dev Removes a key-value pair from a map. O(1). + * + * Returns true if the key was removed from the map, that is if it was present. + */ +function remove(${name} storage map, ${key.typeLoc} key) internal returns (bool) { + delete map._values[key]; + return map._keys.remove(key); +} + +/** + * @dev Removes all the entries from a map. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the map grows to the point where clearing it consumes too much gas to fit in a block. + */ +function clear(${name} storage map) internal { + uint256 len = length(map); + for (uint256 i = 0; i < len; ++i) { + delete map._values[map._keys.at(i)]; + } + map._keys.clear(); +} + +/** + * @dev Returns true if the key is in the map. O(1). + */ +function contains(${name} storage map, ${key.typeLoc} key) internal view returns (bool) { + return map._keys.contains(key); +} + +/** + * @dev Returns the number of key-value pairs in the map. O(1). + */ +function length(${name} storage map) internal view returns (uint256) { + return map._keys.length(); +} + +/** + * @dev Returns the key-value pair stored at position \`index\` in the map. O(1). + * + * Note that there are no guarantees on the ordering of entries inside the + * array, and it may change when more entries are added or removed. + * + * Requirements: + * + * - \`index\` must be strictly less than {length}. + */ +function at(${name} storage map, uint256 index) internal view returns (${key.typeLoc} key, ${value.typeLoc} value) { + key = map._keys.at(index); + value = map._values[key]; +} + +/** + * @dev Tries to returns the value associated with \`key\`. O(1). + * Does not revert if \`key\` is not in the map. + */ +function tryGet(${name} storage map, ${key.typeLoc} key) internal view returns (bool exists, ${value.typeLoc} value) { + value = map._values[key]; + exists = ${value.memory ? 'bytes(value).length != 0' : `value != ${value.type}(0)`} || contains(map, key); +} + +/** + * @dev Returns the value associated with \`key\`. O(1). + * + * Requirements: + * + * - \`key\` must be in the map. + */ +function get(${name} storage map, ${key.typeLoc} key) internal view returns (${value.typeLoc} value) { + bool exists; + (exists, value) = tryGet(map, key); + if (!exists) { + revert EnumerableMapNonexistent${key.name}Key(key); + } +} + +/** + * @dev Return the an array containing all the keys + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the map grows to a point where copying to memory consumes too much gas to fit in a block. + */ +function keys(${name} storage map) internal view returns (${key.type}[] memory) { + return map._keys.values(); +} +`; + +// GENERATE +module.exports = format( + header.trimEnd(), + 'library EnumerableMapExtended {', + format( + [].concat('using EnumerableSet for *;', 'using EnumerableSetExtended for *;', '', EXTENDED_MAP_TYPES.map(map)), + ).trimEnd(), + '}', +); diff --git a/scripts/generate/templates/EnumerableSet.js b/scripts/generate/templates/EnumerableSet.js index 3169d6a46f5..26263ba1889 100644 --- a/scripts/generate/templates/EnumerableSet.js +++ b/scripts/generate/templates/EnumerableSet.js @@ -1,6 +1,6 @@ const format = require('../format-lines'); const { fromBytes32, toBytes32 } = require('./conversion'); -const { TYPES } = require('./EnumerableSet.opts'); +const { SET_TYPES } = require('./Enumerable.opts'); const header = `\ pragma solidity ^0.8.20; @@ -267,7 +267,7 @@ module.exports = format( format( [].concat( defaultSet, - TYPES.map(details => customSet(details)), + SET_TYPES.map(details => customSet(details)), ), ).trimEnd(), '}', diff --git a/scripts/generate/templates/EnumerableSet.opts.js b/scripts/generate/templates/EnumerableSet.opts.js deleted file mode 100644 index 739f0acdfe4..00000000000 --- a/scripts/generate/templates/EnumerableSet.opts.js +++ /dev/null @@ -1,12 +0,0 @@ -const { capitalize } = require('../../helpers'); - -const mapType = str => (str == 'uint256' ? 'Uint' : capitalize(str)); - -const formatType = type => ({ - name: `${mapType(type)}Set`, - type, -}); - -const TYPES = ['bytes32', 'address', 'uint256'].map(formatType); - -module.exports = { TYPES, formatType }; diff --git a/scripts/generate/templates/EnumerableSetExtended.js b/scripts/generate/templates/EnumerableSetExtended.js new file mode 100644 index 00000000000..1899828ba81 --- /dev/null +++ b/scripts/generate/templates/EnumerableSetExtended.js @@ -0,0 +1,319 @@ +const format = require('../format-lines'); +const { EXTENDED_SET_TYPES } = require('./Enumerable.opts'); + +const header = `\ +pragma solidity ^0.8.20; + +import {Hashes} from "../cryptography/Hashes.sol"; + +/** + * @dev Library for managing + * https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets] of non-value + * types. + * + * Sets have the following properties: + * + * - Elements are added, removed, and checked for existence in constant time + * (O(1)). + * - Elements are enumerated in O(n). No guarantees are made on the ordering. + * - Set can be cleared (all elements removed) in O(n). + * + * \`\`\`solidity + * contract Example { + * // Add the library methods + * using EnumerableSetExtended for EnumerableSetExtended.StringSet; + * + * // Declare a set state variable + * EnumerableSetExtended.StringSet private mySet; + * } + * \`\`\` + * + * Sets of type \`string\` (\`StringSet\`), \`bytes\` (\`BytesSet\`) and + * \`bytes32[2]\` (\`Bytes32x2Set\`) are supported. + * + * [WARNING] + * ==== + * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure + * unusable. + * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info. + * + * In order to clean an EnumerableSet, you can either remove all elements one by one or create a fresh instance using an + * array of EnumerableSet. + * ==== + * + * NOTE: This is an extension of {EnumerableSet}. + */ +`; + +const set = ({ name, value }) => `\ +struct ${name} { + // Storage of set values + ${value.type}[] _values; + // Position is the index of the value in the \`values\` array plus 1. + // Position 0 is used to mean a value is not in the set. + mapping(${value.type} value => uint256) _positions; +} + +/** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ +function add(${name} storage self, ${value.type} memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[value] = self._values.length; + return true; + } else { + return false; + } +} + +/** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ +function remove(${name} storage self, ${value.type} memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + uint256 position = self._positions[value]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + ${value.type} memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[lastValue] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[value]; + + return true; + } else { + return false; + } +} + +/** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ +function clear(${name} storage set) internal { + uint256 len = length(set); + for (uint256 i = 0; i < len; ++i) { + delete set._positions[set._values[i]]; + } + // Replace when these are available in Arrays.sol + ${value.type}[] storage array = set._values; + assembly ("memory-safe") { + sstore(array.slot, 0) + } +} + +/** + * @dev Returns true if the value is in the set. O(1). + */ +function contains(${name} storage self, ${value.type} memory value) internal view returns (bool) { + return self._positions[value] != 0; +} + +/** + * @dev Returns the number of values on the set. O(1). + */ +function length(${name} storage self) internal view returns (uint256) { + return self._values.length; +} + +/** + * @dev Returns the value stored at position \`index\` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - \`index\` must be strictly less than {length}. + */ +function at(${name} storage self, uint256 index) internal view returns (${value.type} memory) { + return self._values[index]; +} + +/** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ +function values(${name} storage self) internal view returns (${value.type}[] memory) { + return self._values; +} +`; + +const arraySet = ({ name, value }) => `\ +struct ${name} { + // Storage of set values + ${value.type}[] _values; + // Position is the index of the value in the \`values\` array plus 1. + // Position 0 is used to mean a value is not in the set. + mapping(bytes32 valueHash => uint256) _positions; +} + +/** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ +function add(${name} storage self, ${value.type} memory value) internal returns (bool) { + if (!contains(self, value)) { + self._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + self._positions[_hash(value)] = self._values.length; + return true; + } else { + return false; + } +} + +/** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ +function remove(${name} storage self, ${value.type} memory value) internal returns (bool) { + // We cache the value's position to prevent multiple reads from the same storage slot + bytes32 valueHash = _hash(value); + uint256 position = self._positions[valueHash]; + + if (position != 0) { + // Equivalent to contains(self, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 valueIndex = position - 1; + uint256 lastIndex = self._values.length - 1; + + if (valueIndex != lastIndex) { + ${value.type} memory lastValue = self._values[lastIndex]; + + // Move the lastValue to the index where the value to delete is + self._values[valueIndex] = lastValue; + // Update the tracked position of the lastValue (that was just moved) + self._positions[_hash(lastValue)] = position; + } + + // Delete the slot where the moved value was stored + self._values.pop(); + + // Delete the tracked position for the deleted slot + delete self._positions[valueHash]; + + return true; + } else { + return false; + } +} + +/** + * @dev Removes all the values from a set. O(n). + * + * WARNING: Developers should keep in mind that this function has an unbounded cost and using it may render the + * function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block. + */ +function clear(${name} storage self) internal { + ${value.type}[] storage v = self._values; + + uint256 len = length(self); + for (uint256 i = 0; i < len; ++i) { + delete self._positions[_hash(v[i])]; + } + assembly ("memory-safe") { + sstore(v.slot, 0) + } +} + +/** + * @dev Returns true if the value is in the set. O(1). + */ +function contains(${name} storage self, ${value.type} memory value) internal view returns (bool) { + return self._positions[_hash(value)] != 0; +} + +/** + * @dev Returns the number of values on the set. O(1). + */ +function length(${name} storage self) internal view returns (uint256) { + return self._values.length; +} + +/** + * @dev Returns the value stored at position \`index\` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - \`index\` must be strictly less than {length}. + */ +function at(${name} storage self, uint256 index) internal view returns (${value.type} memory) { + return self._values[index]; +} + +/** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ +function values(${name} storage self) internal view returns (${value.type}[] memory) { + return self._values; +} +`; + +const hashes = `\ +function _hash(bytes32[2] memory value) private pure returns (bytes32) { + return Hashes.efficientKeccak256(value[0], value[1]); +} +`; + +// GENERATE +module.exports = format( + header.trimEnd(), + 'library EnumerableSetExtended {', + format( + [].concat( + EXTENDED_SET_TYPES.filter(({ value }) => value.size == 0).map(set), + EXTENDED_SET_TYPES.filter(({ value }) => value.size > 0).map(arraySet), + hashes, + ), + ).trimEnd(), + '}', +); diff --git a/scripts/upgradeable/upgradeable.patch b/scripts/upgradeable/upgradeable.patch index 5d54ef4203e..66cbdce8334 100644 --- a/scripts/upgradeable/upgradeable.patch +++ b/scripts/upgradeable/upgradeable.patch @@ -110,7 +110,7 @@ index 60d0a430a..0e4f91a6d 100644 } ``` diff --git a/contracts/package.json b/contracts/package.json -index 3682eadeb..4f870d094 100644 +index 70ae73bc2..ef659873f 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,5 +1,5 @@ @@ -118,7 +118,7 @@ index 3682eadeb..4f870d094 100644 - "name": "@openzeppelin/contracts", + "name": "@openzeppelin/contracts-upgradeable", "description": "Secure Smart Contract library for Solidity", - "version": "5.2.0", + "version": "5.3.0", "files": [ @@ -13,7 +13,7 @@ }, @@ -140,7 +140,7 @@ index 3682eadeb..4f870d094 100644 + } } diff --git a/contracts/utils/cryptography/EIP712.sol b/contracts/utils/cryptography/EIP712.sol -index bcb67c87a..7195c3bbd 100644 +index c39954e35..aff99fe86 100644 --- a/contracts/utils/cryptography/EIP712.sol +++ b/contracts/utils/cryptography/EIP712.sol @@ -4,7 +4,6 @@ @@ -224,7 +224,7 @@ index bcb67c87a..7195c3bbd 100644 } /** -@@ -127,6 +108,10 @@ abstract contract EIP712 is IERC5267 { +@@ -125,6 +106,10 @@ abstract contract EIP712 is IERC5267 { uint256[] memory extensions ) { @@ -235,7 +235,7 @@ index bcb67c87a..7195c3bbd 100644 return ( hex"0f", // 01111 _EIP712Name(), -@@ -141,22 +126,62 @@ abstract contract EIP712 is IERC5267 { +@@ -139,22 +124,62 @@ abstract contract EIP712 is IERC5267 { /** * @dev The name parameter for the EIP712 domain. * @@ -309,7 +309,7 @@ index bcb67c87a..7195c3bbd 100644 } } diff --git a/package.json b/package.json -index f9e7d9205..c35020d51 100644 +index 64ee144be..f4665ac9b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ @@ -329,6 +329,36 @@ index 304d1386a..a1cd63bee 100644 -@openzeppelin/contracts/=contracts/ +@openzeppelin/contracts-upgradeable/=contracts/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +diff --git a/test/account/AccountERC7702.test.js b/test/account/AccountERC7702.test.js +index d08a52209..7a44bccfe 100644 +--- a/test/account/AccountERC7702.test.js ++++ b/test/account/AccountERC7702.test.js +@@ -26,8 +26,8 @@ async function fixture() { + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { +- name: 'AccountERC7702Mock', +- version: '1', ++ name: '', // Not initialized in the context of signer ++ version: '', // Not initialized in the context of signer + chainId: entrypointDomain.chainId, + verifyingContract: mock.address, + }; +diff --git a/test/account/examples/AccountERC7702WithModulesMock.test.js b/test/account/examples/AccountERC7702WithModulesMock.test.js +index 9ee5f9177..f6106bcc7 100644 +--- a/test/account/examples/AccountERC7702WithModulesMock.test.js ++++ b/test/account/examples/AccountERC7702WithModulesMock.test.js +@@ -36,8 +36,8 @@ async function fixture() { + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { +- name: 'AccountERC7702WithModulesMock', +- version: '1', ++ name: '', // Not initialized in the context of signer ++ version: '', // Not initialized in the context of signer + chainId: entrypointDomain.chainId, + verifyingContract: mock.address, + }; diff --git a/test/utils/cryptography/EIP712.test.js b/test/utils/cryptography/EIP712.test.js index 2b6e7fa97..268e0d29d 100644 --- a/test/utils/cryptography/EIP712.test.js diff --git a/test/account/Account.behavior.js b/test/account/Account.behavior.js new file mode 100644 index 00000000000..eb10fbb0a85 --- /dev/null +++ b/test/account/Account.behavior.js @@ -0,0 +1,144 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { impersonate } = require('../helpers/account'); +const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('../helpers/erc4337'); +const { shouldSupportInterfaces } = require('../utils/introspection/SupportsInterface.behavior'); + +function shouldBehaveLikeAccountCore() { + describe('entryPoint', function () { + it('should return the canonical entrypoint', async function () { + await this.mock.deploy(); + await expect(this.mock.entryPoint()).to.eventually.equal(entrypoint.v08); + }); + }); + + describe('validateUserOp', function () { + beforeEach(async function () { + await this.other.sendTransaction({ to: this.mock.target, value: ethers.parseEther('1') }); + await this.mock.deploy(); + this.userOp ??= {}; + }); + + it('should revert if the caller is not the canonical entrypoint', async function () { + // empty operation (does nothing) + const operation = await this.mock.createUserOp(this.userOp).then(op => this.signUserOp(op)); + + await expect(this.mock.connect(this.other).validateUserOp(operation.packed, operation.hash(), 0)) + .to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized') + .withArgs(this.other); + }); + + describe('when the caller is the canonical entrypoint', function () { + beforeEach(async function () { + this.mockFromEntrypoint = this.mock.connect(await impersonate(entrypoint.v08.target)); + }); + + it('should return SIG_VALIDATION_SUCCESS if the signature is valid', async function () { + // empty operation (does nothing) + const operation = await this.mock.createUserOp(this.userOp).then(op => this.signUserOp(op)); + + expect(await this.mockFromEntrypoint.validateUserOp.staticCall(operation.packed, operation.hash(), 0)).to.eq( + SIG_VALIDATION_SUCCESS, + ); + }); + + 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 = (await this.invalidSig?.()) ?? '0x00'; + + expect(await this.mockFromEntrypoint.validateUserOp.staticCall(operation.packed, operation.hash(), 0)).to.eq( + SIG_VALIDATION_FAILURE, + ); + }); + + it('should pay missing account funds for execution', async function () { + // empty operation (does nothing) + const operation = await this.mock.createUserOp(this.userOp).then(op => this.signUserOp(op)); + const value = 42n; + + await expect( + this.mockFromEntrypoint.validateUserOp(operation.packed, operation.hash(), value), + ).to.changeEtherBalances([this.mock, entrypoint.v08], [-value, value]); + }); + }); + }); + + describe('fallback', function () { + it('should receive ether', async function () { + await this.mock.deploy(); + const value = 42n; + + await expect(this.other.sendTransaction({ to: this.mock, value })).to.changeEtherBalances( + [this.other, this.mock], + [-value, value], + ); + }); + }); +} + +function shouldBehaveLikeAccountHolder() { + describe('onReceived', function () { + beforeEach(async function () { + await this.mock.deploy(); + }); + + shouldSupportInterfaces(['ERC1155Receiver']); + + describe('onERC1155Received', function () { + const ids = [1n, 2n, 3n]; + const values = [1000n, 2000n, 3000n]; + const data = '0x12345678'; + + beforeEach(async function () { + this.token = await ethers.deployContract('$ERC1155', ['https://somedomain.com/{id}.json']); + await this.token.$_mintBatch(this.other, ids, values, '0x'); + }); + + it('receives ERC1155 tokens from a single ID', async function () { + await this.token.connect(this.other).safeTransferFrom(this.other, this.mock, ids[0], values[0], data); + + await expect( + this.token.balanceOfBatch( + ids.map(() => this.mock), + ids, + ), + ).to.eventually.deep.equal(values.map((v, i) => (i == 0 ? v : 0n))); + }); + + it('receives ERC1155 tokens from a multiple IDs', async function () { + await expect( + this.token.balanceOfBatch( + ids.map(() => this.mock), + ids, + ), + ).to.eventually.deep.equal(ids.map(() => 0n)); + + await this.token.connect(this.other).safeBatchTransferFrom(this.other, this.mock, ids, values, data); + await expect( + this.token.balanceOfBatch( + ids.map(() => this.mock), + ids, + ), + ).to.eventually.deep.equal(values); + }); + }); + + describe('onERC721Received', function () { + const tokenId = 1n; + + beforeEach(async function () { + this.token = await ethers.deployContract('$ERC721', ['Some NFT', 'SNFT']); + await this.token.$_mint(this.other, tokenId); + }); + + it('receives an ERC721 token', async function () { + await this.token.connect(this.other).safeTransferFrom(this.other, this.mock, tokenId); + + await expect(this.token.ownerOf(tokenId)).to.eventually.equal(this.mock); + }); + }); + }); +} + +module.exports = { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder }; diff --git a/test/account/Account.test.js b/test/account/Account.test.js new file mode 100644 index 00000000000..2ccb81ff72f --- /dev/null +++ b/test/account/Account.test.js @@ -0,0 +1,48 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { PackedUserOperation } = require('../helpers/eip712-types'); +const { NonNativeSigner } = require('../helpers/signers'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); +const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); + +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 signer + const signer = new NonNativeSigner({ sign: hash => ({ serialized: hash }) }); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountMock', ['Account', '1']); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { name: 'Account', version: '1', chainId: entrypointDomain.chainId, verifyingContract: mock.address }; + + const signUserOp = async userOp => + signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + return { helper, mock, domain, signer, target, beneficiary, other, signUserOp }; +} + +describe('Account', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); +}); diff --git a/test/account/AccountECDSA.test.js b/test/account/AccountECDSA.test.js new file mode 100644 index 00000000000..d85cc7137f7 --- /dev/null +++ b/test/account/AccountECDSA.test.js @@ -0,0 +1,52 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +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'); + +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 signer + const signer = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountECDSAMock', ['AccountECDSA', '1', signer]); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { + name: 'AccountECDSA', + version: '1', + chainId: entrypointDomain.chainId, + verifyingContract: mock.address, + }; + + const signUserOp = userOp => + signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + return { helper, mock, domain, signer, target, beneficiary, other, signUserOp }; +} + +describe('AccountECDSA', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); +}); diff --git a/test/account/AccountERC7702.t.sol b/test/account/AccountERC7702.t.sol new file mode 100644 index 00000000000..f79e1bcc761 --- /dev/null +++ b/test/account/AccountERC7702.t.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {AccountERC7702Mock} from "@openzeppelin/contracts/mocks/account/AccountMock.sol"; +import {CallReceiverMock} from "@openzeppelin/contracts/mocks/CallReceiverMock.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC7579Utils, Execution, Mode, ModeSelector, ModePayload} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; +import {ERC4337Utils, IEntryPointExtra} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; +import {ERC7821} from "@openzeppelin/contracts/account/extensions/ERC7821.sol"; + +contract AccountERC7702MockConstructor is AccountERC7702Mock { + constructor() EIP712("MyAccount", "1") {} +} + +contract AccountERC7702Test is Test { + using ERC7579Utils for *; + using ERC4337Utils for PackedUserOperation; + using Strings for *; + + uint256 private constant MAX_ETH = type(uint128).max; + + // Test accounts + CallReceiverMock private _target; + + // ERC-4337 signer + uint256 private _signerPrivateKey; + AccountERC7702MockConstructor private _signer; + + function setUp() public { + // Deploy target contract + _target = new CallReceiverMock(); + + // Setup signer + _signerPrivateKey = 0x1234; + _signer = AccountERC7702MockConstructor(payable(vm.addr(_signerPrivateKey))); + vm.deal(address(_signer), MAX_ETH); + + // Sign and attach delegation + vm.signAndAttachDelegation(address(new AccountERC7702MockConstructor()), _signerPrivateKey); + + // Setup entrypoint + vm.deal(address(ERC4337Utils.ENTRYPOINT_V08), MAX_ETH); + vm.etch(address(ERC4337Utils.ENTRYPOINT_V08), vm.readFileBinary("test/bin/EntryPoint070.bytecode")); + } + + function testExecuteBatch(uint256 argA, uint256 argB) public { + // Create the mode for batch execution + Mode mode = ERC7579Utils.CALLTYPE_BATCH.encodeMode( + ERC7579Utils.EXECTYPE_DEFAULT, + ModeSelector.wrap(0x00000000), + ModePayload.wrap(0x00000000) + ); + + Execution[] memory execution = new Execution[](2); + execution[0] = Execution({ + target: address(_target), + value: 1 ether, + callData: abi.encodeCall(CallReceiverMock.mockFunctionExtra, ()) + }); + execution[1] = Execution({ + target: address(_target), + value: 0, + callData: abi.encodeCall(CallReceiverMock.mockFunctionWithArgs, (argA, argB)) + }); + + // Pack the batch within a PackedUserOperation + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = PackedUserOperation({ + sender: address(_signer), + nonce: 0, + initCode: bytes(""), + callData: abi.encodeCall(ERC7821.execute, (Mode.unwrap(mode), execution.encodeBatch())), + preVerificationGas: 100000, + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(100000))), + gasFees: bytes32(abi.encodePacked(uint128(1000000), uint128(1000000))), + paymasterAndData: bytes(""), + signature: bytes("") + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + _signerPrivateKey, + IEntryPointExtra(address(ERC4337Utils.ENTRYPOINT_V08)).getUserOpHash(ops[0]) + ); + ops[0].signature = abi.encodePacked(r, s, v); + + // Expect the events to be emitted + vm.expectEmit(true, true, true, true); + emit CallReceiverMock.MockFunctionCalledExtra(address(_signer), 1 ether); + vm.expectEmit(true, true, true, true); + emit CallReceiverMock.MockFunctionCalledWithArgs(argA, argB); + + // Execute the batch + _signer.entryPoint().handleOps(ops, payable(makeAddr("beneficiary"))); + } +} diff --git a/test/account/AccountERC7702.test.js b/test/account/AccountERC7702.test.js new file mode 100644 index 00000000000..d08a522095f --- /dev/null +++ b/test/account/AccountERC7702.test.js @@ -0,0 +1,52 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +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'); + +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 signer + const signer = ethers.Wallet.createRandom(ethers.provider); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountERC7702Mock', ['AccountERC7702Mock', '1'], { erc7702signer: signer }); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { + name: 'AccountERC7702Mock', + version: '1', + chainId: entrypointDomain.chainId, + verifyingContract: mock.address, + }; + + const signUserOp = userOp => + signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + return { helper, mock, domain, signer, target, beneficiary, other, signUserOp }; +} + +describe('AccountERC7702', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821({ deployable: false }); +}); diff --git a/test/account/AccountP256.test.js b/test/account/AccountP256.test.js new file mode 100644 index 00000000000..a4f7759db2e --- /dev/null +++ b/test/account/AccountP256.test.js @@ -0,0 +1,58 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, P256SigningKey } = 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'); + +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 signer + const signer = new NonNativeSigner(P256SigningKey.random()); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountP256Mock', [ + 'AccountP256', + '1', + signer.signingKey.publicKey.qx, + signer.signingKey.publicKey.qy, + ]); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { + name: 'AccountP256', + version: '1', + chainId: entrypointDomain.chainId, + verifyingContract: mock.address, + }; + + const signUserOp = userOp => + signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + return { helper, mock, domain, signer, target, beneficiary, other, signUserOp }; +} + +describe('AccountP256', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); +}); diff --git a/test/account/AccountRSA.test.js b/test/account/AccountRSA.test.js new file mode 100644 index 00000000000..b8cad78025a --- /dev/null +++ b/test/account/AccountRSA.test.js @@ -0,0 +1,58 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, RSASHA256SigningKey } = 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'); + +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 signer + const signer = new NonNativeSigner(RSASHA256SigningKey.random()); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountRSAMock', [ + 'AccountRSA', + '1', + signer.signingKey.publicKey.e, + signer.signingKey.publicKey.n, + ]); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { + name: 'AccountRSA', + version: '1', + chainId: entrypointDomain.chainId, + verifyingContract: mock.address, + }; + + const signUserOp = userOp => + signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + return { helper, mock, domain, signer, target, beneficiary, other, signUserOp }; +} + +describe('AccountRSA', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); +}); diff --git a/test/account/examples/AccountERC7702WithModulesMock.test.js b/test/account/examples/AccountERC7702WithModulesMock.test.js new file mode 100644 index 00000000000..9ee5f917738 --- /dev/null +++ b/test/account/examples/AccountERC7702WithModulesMock.test.js @@ -0,0 +1,99 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture, setBalance } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { PackedUserOperation } = require('../../helpers/eip712-types'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('../Account.behavior'); +const { shouldBehaveLikeAccountERC7579 } = require('../extensions/AccountERC7579.behavior'); +const { shouldBehaveLikeERC1271 } = require('../../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('../extensions/ERC7821.behavior'); + +const { MODULE_TYPE_VALIDATOR } = require('../../helpers/erc7579'); + +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + const anotherTarget = await ethers.deployContract('CallReceiverMock'); + + // Signer with EIP-7702 support + funding + const eoa = ethers.Wallet.createRandom(ethers.provider); + await setBalance(eoa.address, ethers.WeiPerEther); + + // ERC-7579 validator module + const validator = await ethers.deployContract('$ERC7579ValidatorMock'); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountERC7702WithModulesMock', ['AccountERC7702WithModulesMock', '1'], { + erc7702signer: eoa, + }); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { + name: 'AccountERC7702WithModulesMock', + version: '1', + chainId: entrypointDomain.chainId, + verifyingContract: mock.address, + }; + + return { helper, validator, mock, domain, entrypointDomain, eoa, target, anotherTarget, beneficiary, other }; +} + +describe('AccountERC7702WithModules: ERC-7702 account with ERC-7579 modules supports', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('using ERC-7702 signer', function () { + beforeEach(async function () { + this.signer = this.eoa; + this.signUserOp = userOp => + this.signer + .signTypedData(this.entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC7821({ deployable: false }); + shouldBehaveLikeERC1271({ erc7739: true }); + }); + + describe('using ERC-7579 validator', function () { + beforeEach(async function () { + // signer that adds a prefix to all signatures (except the userOp ones) + this.signer = ethers.Wallet.createRandom(); + 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 })); + + // Use the first 20 bytes from the nonce key (24 bytes) to identify the validator module + this.userOp = { nonce: ethers.zeroPadBytes(ethers.hexlify(this.validator.target), 32) }; + + // Deploy (using ERC-7702) and add the validator module using EOA + await this.mock.deploy(); + await this.mock.connect(this.eoa).installModule(MODULE_TYPE_VALIDATOR, this.validator, this.signer.address); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeAccountERC7579(); + shouldBehaveLikeERC1271({ erc7739: false }); + }); +}); diff --git a/test/account/extensions/AccountERC7579.behavior.js b/test/account/extensions/AccountERC7579.behavior.js new file mode 100644 index 00000000000..1b702fc7727 --- /dev/null +++ b/test/account/extensions/AccountERC7579.behavior.js @@ -0,0 +1,563 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { impersonate } = require('../../helpers/account'); +const { selector } = require('../../helpers/methods'); +const { zip } = require('../../helpers/iterate'); +const { + encodeMode, + encodeBatch, + encodeSingle, + encodeDelegate, + MODULE_TYPE_VALIDATOR, + MODULE_TYPE_EXECUTOR, + MODULE_TYPE_FALLBACK, + MODULE_TYPE_HOOK, + CALL_TYPE_CALL, + CALL_TYPE_BATCH, + CALL_TYPE_DELEGATE, + EXEC_TYPE_DEFAULT, + EXEC_TYPE_TRY, +} = require('../../helpers/erc7579'); + +const CALL_TYPE_INVALID = '0x42'; +const EXEC_TYPE_INVALID = '0x17'; +const MODULE_TYPE_INVALID = 999n; + +const coder = ethers.AbiCoder.defaultAbiCoder(); + +function shouldBehaveLikeAccountERC7579({ withHooks = false } = {}) { + describe('AccountERC7579', function () { + beforeEach(async function () { + await this.mock.deploy(); + await this.other.sendTransaction({ to: this.mock.target, value: ethers.parseEther('1') }); + + this.modules = {}; + this.modules[MODULE_TYPE_VALIDATOR] = await ethers.deployContract('$ERC7579ModuleMock', [MODULE_TYPE_VALIDATOR]); + this.modules[MODULE_TYPE_EXECUTOR] = await ethers.deployContract('$ERC7579ModuleMock', [MODULE_TYPE_EXECUTOR]); + this.modules[MODULE_TYPE_FALLBACK] = await ethers.deployContract('$ERC7579ModuleMock', [MODULE_TYPE_FALLBACK]); + this.modules[MODULE_TYPE_HOOK] = await ethers.deployContract('$ERC7579HookMock'); + + this.mockFromEntrypoint = this.mock.connect(await impersonate(entrypoint.v08.target)); + this.mockFromExecutor = this.mock.connect(await impersonate(this.modules[MODULE_TYPE_EXECUTOR].target)); + }); + + describe('accountId', function () { + it('should return the account ID', async function () { + await expect(this.mock.accountId()).to.eventually.equal( + withHooks + ? '@openzeppelin/community-contracts.AccountERC7579Hooked.v0.0.0' + : '@openzeppelin/community-contracts.AccountERC7579.v0.0.0', + ); + }); + }); + + describe('supportsExecutionMode', function () { + for (const [callType, execType] of zip( + [CALL_TYPE_CALL, CALL_TYPE_BATCH, CALL_TYPE_DELEGATE, CALL_TYPE_INVALID], + [EXEC_TYPE_DEFAULT, EXEC_TYPE_TRY, EXEC_TYPE_INVALID], + )) { + const result = callType != CALL_TYPE_INVALID && execType != EXEC_TYPE_INVALID; + + it(`${ + result ? 'does not support' : 'supports' + } CALL_TYPE=${callType} and EXEC_TYPE=${execType} execution mode`, async function () { + await expect(this.mock.supportsExecutionMode(encodeMode({ callType, execType }))).to.eventually.equal(result); + }); + } + }); + + describe('supportsModule', function () { + it('supports MODULE_TYPE_VALIDATOR module type', async function () { + await expect(this.mock.supportsModule(MODULE_TYPE_VALIDATOR)).to.eventually.equal(true); + }); + + it('supports MODULE_TYPE_EXECUTOR module type', async function () { + await expect(this.mock.supportsModule(MODULE_TYPE_EXECUTOR)).to.eventually.equal(true); + }); + + it('supports MODULE_TYPE_FALLBACK module type', async function () { + await expect(this.mock.supportsModule(MODULE_TYPE_FALLBACK)).to.eventually.equal(true); + }); + + it( + withHooks ? 'supports MODULE_TYPE_HOOK module type' : 'does not support MODULE_TYPE_HOOK module type', + async function () { + await expect(this.mock.supportsModule(MODULE_TYPE_HOOK)).to.eventually.equal(withHooks); + }, + ); + + it('does not support invalid module type', async function () { + await expect(this.mock.supportsModule(MODULE_TYPE_INVALID)).to.eventually.equal(false); + }); + }); + + describe('module installation', function () { + it('should revert if the caller is not the canonical entrypoint or the account itself', async function () { + await expect(this.mock.connect(this.other).installModule(MODULE_TYPE_VALIDATOR, this.mock, '0x')) + .to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized') + .withArgs(this.other); + }); + + it('should revert if the module type is not supported', async function () { + await expect(this.mockFromEntrypoint.installModule(MODULE_TYPE_INVALID, this.mock, '0x')) + .to.be.revertedWithCustomError(this.mock, 'ERC7579UnsupportedModuleType') + .withArgs(MODULE_TYPE_INVALID); + }); + + it('should revert if the module is not the provided type', async function () { + const instance = this.modules[MODULE_TYPE_EXECUTOR]; + await expect(this.mockFromEntrypoint.installModule(MODULE_TYPE_VALIDATOR, instance, '0x')) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MismatchedModuleTypeId') + .withArgs(MODULE_TYPE_VALIDATOR, instance); + }); + + for (const moduleTypeId of [ + MODULE_TYPE_VALIDATOR, + MODULE_TYPE_EXECUTOR, + MODULE_TYPE_FALLBACK, + withHooks && MODULE_TYPE_HOOK, + ].filter(Boolean)) { + const prefix = moduleTypeId == MODULE_TYPE_FALLBACK ? '0x12345678' : '0x'; + const initData = ethers.hexlify(ethers.randomBytes(256)); + const fullData = ethers.concat([prefix, initData]); + + it(`should install a module of type ${moduleTypeId}`, async function () { + const instance = this.modules[moduleTypeId]; + + await expect(this.mock.isModuleInstalled(moduleTypeId, instance, fullData)).to.eventually.equal(false); + + await expect(this.mockFromEntrypoint.installModule(moduleTypeId, instance, fullData)) + .to.emit(this.mock, 'ModuleInstalled') + .withArgs(moduleTypeId, instance) + .to.emit(instance, 'ModuleInstalledReceived') + .withArgs(this.mock, initData); // After decoding MODULE_TYPE_FALLBACK, it should remove the fnSig + + await expect(this.mock.isModuleInstalled(moduleTypeId, instance, fullData)).to.eventually.equal(true); + }); + + it(`does not allow to install a module of ${moduleTypeId} id twice`, async function () { + const instance = this.modules[moduleTypeId]; + + await this.mockFromEntrypoint.installModule(moduleTypeId, instance, fullData); + + await expect(this.mock.isModuleInstalled(moduleTypeId, instance, fullData)).to.eventually.equal(true); + + await expect(this.mockFromEntrypoint.installModule(moduleTypeId, instance, fullData)) + .to.be.revertedWithCustomError( + this.mock, + moduleTypeId == MODULE_TYPE_HOOK ? 'ERC7579HookModuleAlreadyPresent' : 'ERC7579AlreadyInstalledModule', + ) + .withArgs(...[moduleTypeId != MODULE_TYPE_HOOK && moduleTypeId, instance].filter(Boolean)); + }); + } + + withHooks && + describe('with hook', function () { + beforeEach(async function () { + await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x'); + }); + + it('should call the hook of the installed module when performing an module install', async function () { + const instance = this.modules[MODULE_TYPE_EXECUTOR]; + const initData = ethers.hexlify(ethers.randomBytes(256)); + + const precheckData = this.mock.interface.encodeFunctionData('installModule', [ + MODULE_TYPE_EXECUTOR, + instance.target, + initData, + ]); + + await expect(this.mockFromEntrypoint.installModule(MODULE_TYPE_EXECUTOR, instance, initData)) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PreCheck') + .withArgs(entrypoint.v08, 0n, precheckData) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PostCheck') + .withArgs(precheckData); + }); + }); + }); + + describe('module uninstallation', function () { + it('should revert if the caller is not the canonical entrypoint or the account itself', async function () { + await expect(this.mock.connect(this.other).uninstallModule(MODULE_TYPE_VALIDATOR, this.mock, '0x')) + .to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized') + .withArgs(this.other); + }); + + it('should revert if the module type is not supported', async function () { + await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_INVALID, this.mock, '0x')) + .to.be.revertedWithCustomError(this.mock, 'ERC7579UnsupportedModuleType') + .withArgs(MODULE_TYPE_INVALID); + }); + + for (const moduleTypeId of [ + MODULE_TYPE_VALIDATOR, + MODULE_TYPE_EXECUTOR, + MODULE_TYPE_FALLBACK, + withHooks && MODULE_TYPE_HOOK, + ].filter(Boolean)) { + const prefix = moduleTypeId == MODULE_TYPE_FALLBACK ? '0x12345678' : '0x'; + const initData = ethers.hexlify(ethers.randomBytes(256)); + const fullData = ethers.concat([prefix, initData]); + + it(`should uninstall a module of type ${moduleTypeId}`, async function () { + const instance = this.modules[moduleTypeId]; + + await this.mock.$_installModule(moduleTypeId, instance, fullData); + + await expect(this.mock.isModuleInstalled(moduleTypeId, instance, fullData)).to.eventually.equal(true); + + await expect(this.mockFromEntrypoint.uninstallModule(moduleTypeId, instance, fullData)) + .to.emit(this.mock, 'ModuleUninstalled') + .withArgs(moduleTypeId, instance) + .to.emit(instance, 'ModuleUninstalledReceived') + .withArgs(this.mock, initData); // After decoding MODULE_TYPE_FALLBACK, it should remove the fnSig + + await expect(this.mock.isModuleInstalled(moduleTypeId, instance, fullData)).to.eventually.equal(false); + }); + + it(`should revert uninstalling a module of type ${moduleTypeId} if it was not installed`, async function () { + const instance = this.modules[moduleTypeId]; + + await expect(this.mockFromEntrypoint.uninstallModule(moduleTypeId, instance, fullData)) + .to.be.revertedWithCustomError(this.mock, 'ERC7579UninstalledModule') + .withArgs(moduleTypeId, instance); + }); + } + + it('should revert uninstalling a module of type MODULE_TYPE_FALLBACK if a different module was installed for the provided selector', async function () { + const instance = this.modules[MODULE_TYPE_FALLBACK]; + const anotherInstance = await ethers.deployContract('$ERC7579ModuleMock', [MODULE_TYPE_FALLBACK]); + const initData = '0x12345678abcdef'; + + await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_FALLBACK, instance, initData); + await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_FALLBACK, anotherInstance, initData)) + .to.be.revertedWithCustomError(this.mock, 'ERC7579UninstalledModule') + .withArgs(MODULE_TYPE_FALLBACK, anotherInstance); + }); + + withHooks && + describe('with hook', function () { + beforeEach(async function () { + await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x'); + }); + + it('should call the hook of the installed module when performing a module uninstall', async function () { + const instance = this.modules[MODULE_TYPE_EXECUTOR]; + const initData = ethers.hexlify(ethers.randomBytes(256)); + + const precheckData = this.mock.interface.encodeFunctionData('uninstallModule', [ + MODULE_TYPE_EXECUTOR, + instance.target, + initData, + ]); + + await this.mock.$_installModule(MODULE_TYPE_EXECUTOR, instance, initData); + await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_EXECUTOR, instance, initData)) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PreCheck') + .withArgs(entrypoint.v08, 0n, precheckData) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PostCheck') + .withArgs(precheckData); + }); + }); + }); + + describe('execution', function () { + beforeEach(async function () { + await this.mock.$_installModule(MODULE_TYPE_EXECUTOR, this.modules[MODULE_TYPE_EXECUTOR], '0x'); + }); + + for (const [execFn, mock] of [ + ['execute', 'mockFromEntrypoint'], + ['executeFromExecutor', 'mockFromExecutor'], + ]) { + describe(`executing with ${execFn}`, function () { + it('should revert if the call type is not supported', async function () { + await expect( + this[mock][execFn](encodeMode({ callType: CALL_TYPE_INVALID }), encodeSingle(this.other, 0, '0x')), + ) + .to.be.revertedWithCustomError(this.mock, 'ERC7579UnsupportedCallType') + .withArgs(ethers.solidityPacked(['bytes1'], [CALL_TYPE_INVALID])); + }); + + it('should revert if the caller is not authorized / installed', async function () { + const error = execFn == 'execute' ? 'AccountUnauthorized' : 'ERC7579UninstalledModule'; + const args = execFn == 'execute' ? [this.other] : [MODULE_TYPE_EXECUTOR, this.other]; + + await expect( + this[mock] + .connect(this.other) + [execFn](encodeMode({ callType: CALL_TYPE_CALL }), encodeSingle(this.other, 0, '0x')), + ) + .to.be.revertedWithCustomError(this.mock, error) + .withArgs(...args); + }); + + describe('single execution', function () { + it('calls the target with value and args', async function () { + const value = 0x432; + const data = encodeSingle( + this.target, + value, + this.target.interface.encodeFunctionData('mockFunctionWithArgs', [42, '0x1234']), + ); + + const tx = this[mock][execFn](encodeMode({ callType: CALL_TYPE_CALL }), data); + + await expect(tx).to.emit(this.target, 'MockFunctionCalledWithArgs').withArgs(42, '0x1234'); + await expect(tx).to.changeEtherBalances([this.mock, this.target], [-value, value]); + }); + + it('reverts when target reverts in default ExecType', async function () { + const value = 0x012; + const data = encodeSingle( + this.target, + value, + this.target.interface.encodeFunctionData('mockFunctionRevertsReason'), + ); + + await expect(this[mock][execFn](encodeMode({ callType: CALL_TYPE_CALL }), data)).to.be.revertedWith( + 'CallReceiverMock: reverting', + ); + }); + + it('emits ERC7579TryExecuteFail event when target reverts in try ExecType', async function () { + const value = 0x012; + const data = encodeSingle( + this.target, + value, + this.target.interface.encodeFunctionData('mockFunctionRevertsReason'), + ); + + await expect(this[mock][execFn](encodeMode({ callType: CALL_TYPE_CALL, execType: EXEC_TYPE_TRY }), data)) + .to.emit(this.mock, 'ERC7579TryExecuteFail') + .withArgs( + CALL_TYPE_CALL, + ethers.solidityPacked( + ['bytes4', 'bytes'], + [selector('Error(string)'), coder.encode(['string'], ['CallReceiverMock: reverting'])], + ), + ); + }); + }); + + describe('batch execution', function () { + it('calls the targets with value and args', async function () { + const value1 = 0x012; + const value2 = 0x234; + const data = encodeBatch( + [this.target, value1, this.target.interface.encodeFunctionData('mockFunctionWithArgs', [42, '0x1234'])], + [ + this.anotherTarget, + value2, + this.anotherTarget.interface.encodeFunctionData('mockFunctionWithArgs', [42, '0x1234']), + ], + ); + + const tx = this[mock][execFn](encodeMode({ callType: CALL_TYPE_BATCH }), data); + await expect(tx) + .to.emit(this.target, 'MockFunctionCalledWithArgs') + .to.emit(this.anotherTarget, 'MockFunctionCalledWithArgs'); + await expect(tx).to.changeEtherBalances( + [this.mock, this.target, this.anotherTarget], + [-value1 - value2, value1, value2], + ); + }); + + it('reverts when any target reverts in default ExecType', async function () { + const value1 = 0x012; + const value2 = 0x234; + const data = encodeBatch( + [this.target, value1, this.target.interface.encodeFunctionData('mockFunction')], + [ + this.anotherTarget, + value2, + this.anotherTarget.interface.encodeFunctionData('mockFunctionRevertsReason'), + ], + ); + + await expect(this[mock][execFn](encodeMode({ callType: CALL_TYPE_BATCH }), data)).to.be.revertedWith( + 'CallReceiverMock: reverting', + ); + }); + + it('emits ERC7579TryExecuteFail event when any target reverts in try ExecType', async function () { + const value1 = 0x012; + const value2 = 0x234; + const data = encodeBatch( + [this.target, value1, this.target.interface.encodeFunctionData('mockFunction')], + [ + this.anotherTarget, + value2, + this.anotherTarget.interface.encodeFunctionData('mockFunctionRevertsReason'), + ], + ); + + const tx = this[mock][execFn](encodeMode({ callType: CALL_TYPE_BATCH, execType: EXEC_TYPE_TRY }), data); + + await expect(tx) + .to.emit(this.mock, 'ERC7579TryExecuteFail') + .withArgs( + CALL_TYPE_BATCH, + ethers.solidityPacked( + ['bytes4', 'bytes'], + [selector('Error(string)'), coder.encode(['string'], ['CallReceiverMock: reverting'])], + ), + ); + + await expect(tx).to.changeEtherBalances( + [this.mock, this.target, this.anotherTarget], + [-value1, value1, 0], + ); + }); + }); + + describe('delegate call execution', function () { + it('delegate calls the target', async function () { + const slot = ethers.hexlify(ethers.randomBytes(32)); + const value = ethers.hexlify(ethers.randomBytes(32)); + const data = encodeDelegate( + this.target, + this.target.interface.encodeFunctionData('mockFunctionWritesStorage', [slot, value]), + ); + + await expect(ethers.provider.getStorage(this.mock.target, slot)).to.eventually.equal(ethers.ZeroHash); + await this[mock][execFn](encodeMode({ callType: CALL_TYPE_DELEGATE }), data); + await expect(ethers.provider.getStorage(this.mock.target, slot)).to.eventually.equal(value); + }); + + it('reverts when target reverts in default ExecType', async function () { + const data = encodeDelegate( + this.target, + this.target.interface.encodeFunctionData('mockFunctionRevertsReason'), + ); + await expect(this[mock][execFn](encodeMode({ callType: CALL_TYPE_DELEGATE }), data)).to.be.revertedWith( + 'CallReceiverMock: reverting', + ); + }); + + it('emits ERC7579TryExecuteFail event when target reverts in try ExecType', async function () { + const data = encodeDelegate( + this.target, + this.target.interface.encodeFunctionData('mockFunctionRevertsReason'), + ); + await expect( + this[mock][execFn](encodeMode({ callType: CALL_TYPE_DELEGATE, execType: EXEC_TYPE_TRY }), data), + ) + .to.emit(this.mock, 'ERC7579TryExecuteFail') + .withArgs( + CALL_TYPE_CALL, + ethers.solidityPacked( + ['bytes4', 'bytes'], + [selector('Error(string)'), coder.encode(['string'], ['CallReceiverMock: reverting'])], + ), + ); + }); + }); + + withHooks && + describe('with hook', function () { + beforeEach(async function () { + await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x'); + }); + + it(`should call the hook of the installed module when executing ${execFn}`, async function () { + const caller = execFn === 'execute' ? entrypoint.v08 : this.modules[MODULE_TYPE_EXECUTOR]; + const value = 17; + const data = this.target.interface.encodeFunctionData('mockFunctionWithArgs', [42, '0x1234']); + + const mode = encodeMode({ callType: CALL_TYPE_CALL }); + const call = encodeSingle(this.target, value, data); + const precheckData = this[mock].interface.encodeFunctionData(execFn, [mode, call]); + + const tx = this[mock][execFn](mode, call, { value }); + + await expect(tx) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PreCheck') + .withArgs(caller, value, precheckData) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PostCheck') + .withArgs(precheckData); + await expect(tx).to.changeEtherBalances([caller, this.mock, this.target], [-value, 0n, value]); + }); + }); + }); + } + }); + + describe('fallback', function () { + beforeEach(async function () { + this.fallbackHandler = await ethers.deployContract('$ERC7579FallbackHandlerMock'); + }); + + it('reverts if there is no fallback module installed', async function () { + const { selector } = this.fallbackHandler.callPayable.getFragment(); + + await expect(this.fallbackHandler.attach(this.mock).callPayable()) + .to.be.revertedWithCustomError(this.mock, 'ERC7579MissingFallbackHandler') + .withArgs(selector); + }); + + describe('with a fallback module installed', function () { + beforeEach(async function () { + await Promise.all( + [ + this.fallbackHandler.callPayable.getFragment().selector, + this.fallbackHandler.callView.getFragment().selector, + this.fallbackHandler.callRevert.getFragment().selector, + ].map(selector => + this.mock.$_installModule( + MODULE_TYPE_FALLBACK, + this.fallbackHandler, + coder.encode(['bytes4', 'bytes'], [selector, '0x']), + ), + ), + ); + }); + + it('forwards the call to the fallback handler', async function () { + const calldata = this.fallbackHandler.interface.encodeFunctionData('callPayable'); + const value = 17n; + + await expect(this.fallbackHandler.attach(this.mock).connect(this.other).callPayable({ value })) + .to.emit(this.fallbackHandler, 'ERC7579FallbackHandlerMockCalled') + .withArgs(this.mock, this.other, value, calldata); + }); + + it('returns answer from the fallback handler', async function () { + await expect(this.fallbackHandler.attach(this.mock).connect(this.other).callView()).to.eventually.deep.equal([ + this.mock.target, + this.other.address, + ]); + }); + + it('bubble up reverts from the fallback handler', async function () { + await expect( + this.fallbackHandler.attach(this.mock).connect(this.other).callRevert(), + ).to.be.revertedWithCustomError(this.fallbackHandler, 'ERC7579FallbackHandlerMockRevert'); + }); + + withHooks && + describe('with hook', function () { + beforeEach(async function () { + await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x'); + }); + + it('should call the hook of the installed module when performing a callback', async function () { + const precheckData = this.fallbackHandler.interface.encodeFunctionData('callPayable'); + const value = 17n; + + // call with interface: decode returned data + await expect(this.fallbackHandler.attach(this.mock).connect(this.other).callPayable({ value })) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PreCheck') + .withArgs(this.other, value, precheckData) + .to.emit(this.modules[MODULE_TYPE_HOOK], 'PostCheck') + .withArgs(precheckData); + }); + }); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeAccountERC7579, +}; diff --git a/test/account/extensions/AccountERC7579.test.js b/test/account/extensions/AccountERC7579.test.js new file mode 100644 index 00000000000..7903c91aecc --- /dev/null +++ b/test/account/extensions/AccountERC7579.test.js @@ -0,0 +1,60 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { PackedUserOperation } = require('../../helpers/eip712-types'); + +const { shouldBehaveLikeAccountCore } = require('../Account.behavior'); +const { shouldBehaveLikeAccountERC7579 } = require('./AccountERC7579.behavior'); +const { shouldBehaveLikeERC1271 } = require('../../utils/cryptography/ERC1271.behavior'); + +async function fixture() { + // EOAs and environment + const [other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + const anotherTarget = await ethers.deployContract('CallReceiverMock'); + + // ERC-7579 validator + const validator = await ethers.deployContract('$ERC7579ValidatorMock'); + + // ERC-4337 signer + const signer = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountERC7579Mock', [ + validator, + ethers.solidityPacked(['address'], [signer.address]), + ]); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + return { helper, validator, mock, entrypointDomain, signer, target, anotherTarget, other }; +} + +describe('AccountERC7579', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + + 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(); +}); diff --git a/test/account/extensions/AccountERC7579Hooked.test.js b/test/account/extensions/AccountERC7579Hooked.test.js new file mode 100644 index 00000000000..6db8fe9b691 --- /dev/null +++ b/test/account/extensions/AccountERC7579Hooked.test.js @@ -0,0 +1,60 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { PackedUserOperation } = require('../../helpers/eip712-types'); + +const { shouldBehaveLikeAccountCore } = require('../Account.behavior'); +const { shouldBehaveLikeAccountERC7579 } = require('./AccountERC7579.behavior'); +const { shouldBehaveLikeERC1271 } = require('../../utils/cryptography/ERC1271.behavior'); + +async function fixture() { + // EOAs and environment + const [other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + const anotherTarget = await ethers.deployContract('CallReceiverMock'); + + // ERC-7579 validator + const validator = await ethers.deployContract('$ERC7579ValidatorMock'); + + // ERC-4337 signer + const signer = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountERC7579HookedMock', [ + validator, + ethers.solidityPacked(['address'], [signer.address]), + ]); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + return { helper, validator, mock, entrypointDomain, signer, target, anotherTarget, other }; +} + +describe('AccountERC7579Hooked', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + + 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({ withHooks: true }); + shouldBehaveLikeERC1271(); +}); diff --git a/test/account/extensions/ERC7821.behavior.js b/test/account/extensions/ERC7821.behavior.js new file mode 100644 index 00000000000..d6bff8b15a2 --- /dev/null +++ b/test/account/extensions/ERC7821.behavior.js @@ -0,0 +1,145 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); + +const { CALL_TYPE_BATCH, encodeMode, encodeBatch } = require('../../helpers/erc7579'); + +function shouldBehaveLikeERC7821({ deployable = true } = {}) { + describe('supports ERC-7821', function () { + beforeEach(async function () { + // give eth to the account (before deployment) + await this.other.sendTransaction({ to: this.mock.target, value: ethers.parseEther('1') }); + + // account is not initially deployed + await expect(ethers.provider.getCode(this.mock)).to.eventually.equal('0x'); + + this.encodeUserOpCalldata = (...calls) => + this.mock.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch(...calls), + ]); + }); + + it('should revert if the caller is not the canonical entrypoint or the account itself', async function () { + await this.mock.deploy(); + + await expect( + this.mock.connect(this.other).execute( + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch({ + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ), + ) + .to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized') + .withArgs(this.other); + }); + + if (deployable) { + describe('when not deployed', function () { + it('should be created with handleOps and increase nonce', async function () { + const operation = await this.mock + .createUserOp({ + callData: this.encodeUserOpCalldata({ + target: this.target, + value: 17, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + }) + .then(op => op.addInitCode()) + .then(op => this.signUserOp(op)); + + // Can't call the account to get its nonce before it's deployed + await expect(entrypoint.v08.getNonce(this.mock.target, 0)).to.eventually.equal(0); + await expect(entrypoint.v08.handleOps([operation.packed], this.beneficiary)) + .to.emit(entrypoint.v08, 'AccountDeployed') + .withArgs(operation.hash(), this.mock, this.helper.factory, ethers.ZeroAddress) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.mock, 17); + await expect(this.mock.getNonce()).to.eventually.equal(1); + }); + + it('should revert if the signature is invalid', async function () { + const operation = await this.mock + .createUserOp({ + callData: this.encodeUserOpCalldata({ + target: this.target, + value: 17, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + }) + .then(op => op.addInitCode()); + + operation.signature = '0x00'; + + await expect(entrypoint.v08.handleOps([operation.packed], this.beneficiary)).to.be.reverted; + }); + }); + } + + describe('when deployed', function () { + beforeEach(async function () { + await this.mock.deploy(); + }); + + it('should increase nonce and call target', async function () { + const operation = await this.mock + .createUserOp({ + callData: this.encodeUserOpCalldata({ + target: this.target, + value: 42, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + }) + .then(op => this.signUserOp(op)); + + await expect(this.mock.getNonce()).to.eventually.equal(0); + await expect(entrypoint.v08.handleOps([operation.packed], this.beneficiary)) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.mock, 42); + await expect(this.mock.getNonce()).to.eventually.equal(1); + }); + + it('should support sending eth to an EOA', async function () { + const operation = await this.mock + .createUserOp({ callData: this.encodeUserOpCalldata({ target: this.other, value: 42 }) }) + .then(op => this.signUserOp(op)); + + await expect(this.mock.getNonce()).to.eventually.equal(0); + await expect(entrypoint.v08.handleOps([operation.packed], this.beneficiary)).to.changeEtherBalance( + this.other, + 42, + ); + await expect(this.mock.getNonce()).to.eventually.equal(1); + }); + + it('should support batch execution', async function () { + const value1 = 43374337n; + const value2 = 69420n; + + const operation = await this.mock + .createUserOp({ + callData: this.encodeUserOpCalldata( + { target: this.other, value: value1 }, + { + target: this.target, + value: value2, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }, + ), + }) + .then(op => this.signUserOp(op)); + + await expect(this.mock.getNonce()).to.eventually.equal(0); + const tx = entrypoint.v08.handleOps([operation.packed], this.beneficiary); + await expect(tx).to.changeEtherBalances([this.other, this.target], [value1, value2]); + await expect(tx).to.emit(this.target, 'MockFunctionCalledExtra').withArgs(this.mock, value2); + await expect(this.mock.getNonce()).to.eventually.equal(1); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC7821, +}; diff --git a/test/helpers/eip712-types.js b/test/helpers/eip712-types.js index d04969e442b..fb6fe3aebaf 100644 --- a/test/helpers/eip712-types.js +++ b/test/helpers/eip712-types.js @@ -11,19 +11,8 @@ module.exports = mapValues( verifyingContract: 'address', salt: 'bytes32', }, - Permit: { - owner: 'address', - spender: 'address', - value: 'uint256', - nonce: 'uint256', - deadline: 'uint256', - }, - Ballot: { - proposalId: 'uint256', - support: 'uint8', - voter: 'address', - nonce: 'uint256', - }, + Permit: { owner: 'address', spender: 'address', value: 'uint256', nonce: 'uint256', deadline: 'uint256' }, + Ballot: { proposalId: 'uint256', support: 'uint8', voter: 'address', nonce: 'uint256' }, ExtendedBallot: { proposalId: 'uint256', support: 'uint8', @@ -32,18 +21,8 @@ module.exports = mapValues( reason: 'string', params: 'bytes', }, - OverrideBallot: { - proposalId: 'uint256', - support: 'uint8', - voter: 'address', - nonce: 'uint256', - reason: 'string', - }, - Delegation: { - delegatee: 'address', - nonce: 'uint256', - expiry: 'uint256', - }, + OverrideBallot: { proposalId: 'uint256', support: 'uint8', voter: 'address', nonce: 'uint256', reason: 'string' }, + Delegation: { delegatee: 'address', nonce: 'uint256', expiry: 'uint256' }, ForwardRequest: { from: 'address', to: 'address', @@ -53,6 +32,29 @@ module.exports = mapValues( deadline: 'uint48', data: 'bytes', }, + PackedUserOperation: { + sender: 'address', + nonce: 'uint256', + initCode: 'bytes', + callData: 'bytes', + accountGasLimits: 'bytes32', + preVerificationGas: 'uint256', + gasFees: 'bytes32', + paymasterAndData: 'bytes', + }, + UserOperationRequest: { + sender: 'address', + nonce: 'uint256', + initCode: 'bytes', + callData: 'bytes', + accountGasLimits: 'bytes32', + preVerificationGas: 'uint256', + gasFees: 'bytes32', + paymasterVerificationGasLimit: 'uint256', + paymasterPostOpGasLimit: 'uint256', + validAfter: 'uint48', + validUntil: 'uint48', + }, }, formatType, ); diff --git a/test/helpers/erc4337.js b/test/helpers/erc4337.js index 2d5cbe1a0bf..0d5421bec75 100644 --- a/test/helpers/erc4337.js +++ b/test/helpers/erc4337.js @@ -1,4 +1,4 @@ -const { ethers } = require('hardhat'); +const { ethers, config, entrypoint, senderCreator } = require('hardhat'); const SIG_VALIDATION_SUCCESS = '0x0000000000000000000000000000000000000000'; const SIG_VALIDATION_FAILURE = '0x0000000000000000000000000000000000000001'; @@ -83,6 +83,129 @@ class UserOperation { } } +const parseInitCode = initCode => ({ + factory: '0x' + initCode.replace(/0x/, '').slice(0, 40), + factoryData: '0x' + initCode.replace(/0x/, '').slice(40), +}); + +/// Global ERC-4337 environment helper. +class ERC4337Helper { + constructor() { + this.factoryAsPromise = ethers.deployContract('$Create2'); + } + + async wait() { + this.factory = await this.factoryAsPromise; + return this; + } + + async newAccount(name, extraArgs = [], params = {}) { + const env = { + entrypoint: params.entrypoint ?? entrypoint.v08, + senderCreator: params.senderCreator ?? senderCreator.v08, + }; + + const { factory } = await this.wait(); + + const accountFactory = await ethers.getContractFactory(name); + + if (params.erc7702signer) { + const delegate = await accountFactory.deploy(...extraArgs); + const instance = await params.erc7702signer.getAddress().then(address => accountFactory.attach(address)); + const authorization = await params.erc7702signer.authorize({ address: delegate.target }); + return new ERC7702SmartAccount(instance, authorization, env); + } else { + const initCode = await accountFactory + .getDeployTransaction(...extraArgs) + .then(tx => + factory.interface.encodeFunctionData('$deploy', [0, params.salt ?? ethers.randomBytes(32), tx.data]), + ) + .then(deployCode => ethers.concat([factory.target, deployCode])); + + const instance = await ethers.provider + .call({ + from: env.entrypoint, + to: env.senderCreator, + data: env.senderCreator.interface.encodeFunctionData('createSender', [initCode]), + }) + .then(result => ethers.getAddress(ethers.hexlify(ethers.getBytes(result).slice(-20)))) + .then(address => accountFactory.attach(address)); + + return new SmartAccount(instance, initCode, env); + } + } +} + +/// Represent one ERC-4337 account contract. +class SmartAccount extends ethers.BaseContract { + constructor(instance, initCode, env) { + super(instance.target, instance.interface, instance.runner, instance.deployTx); + this.address = instance.target; + this.initCode = initCode; + this._env = env; + } + + async deploy(account = this.runner) { + const { factory: to, factoryData: data } = parseInitCode(this.initCode); + this.deployTx = await account.sendTransaction({ to, data }); + return this; + } + + async createUserOp(userOp = {}) { + userOp.sender ??= this; + userOp.nonce ??= await this._env.entrypoint.getNonce(userOp.sender, 0); + if (ethers.isAddressable(userOp.paymaster)) { + userOp.paymaster = await ethers.resolveAddress(userOp.paymaster); + userOp.paymasterVerificationGasLimit ??= 100_000n; + userOp.paymasterPostOpGasLimit ??= 100_000n; + } + return new UserOperationWithContext(userOp, this._env); + } +} + +class ERC7702SmartAccount extends SmartAccount { + constructor(instance, authorization, env) { + super(instance, undefined, env); + this.authorization = authorization; + } + + async deploy() { + // hardhat signers from @nomicfoundation/hardhat-ethers do not support type 4 txs. + // so we rebuild it using "native" ethers + await ethers.Wallet.fromPhrase(config.networks.hardhat.accounts.mnemonic, ethers.provider).sendTransaction({ + to: ethers.ZeroAddress, + authorizationList: [this.authorization], + gasLimit: 46_000n, // 21,000 base + PER_EMPTY_ACCOUNT_COST + }); + + return this; + } +} + +class UserOperationWithContext extends UserOperation { + constructor(userOp, env) { + super(userOp); + this._sender = userOp.sender; + this._env = env; + } + + addInitCode() { + if (this._sender?.initCode) { + return Object.assign(this, parseInitCode(this._sender.initCode)); + } else throw new Error('No init code available for the sender of this user operation'); + } + + getAuthorization() { + if (this._sender?.authorization) { + return this._sender.authorization; + } else throw new Error('No EIP-7702 authorization available for the sender of this user operation'); + } + + hash() { + return super.hash(this._env.entrypoint); + } +} + module.exports = { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE, @@ -90,4 +213,5 @@ module.exports = { packInitCode, packPaymasterAndData, UserOperation, + ERC4337Helper, }; diff --git a/test/helpers/erc7739.js b/test/helpers/erc7739.js new file mode 100644 index 00000000000..5a489de71e9 --- /dev/null +++ b/test/helpers/erc7739.js @@ -0,0 +1,118 @@ +const { ethers } = require('hardhat'); +const { formatType } = require('./eip712'); + +const PersonalSign = formatType({ prefixed: 'bytes' }); +const TypedDataSign = contentsTypeName => + formatType({ + contents: contentsTypeName, + name: 'string', + version: 'string', + chainId: 'uint256', + verifyingContract: 'address', + salt: 'bytes32', + }); + +class ERC7739Signer extends ethers.AbstractSigner { + #signer; + #domain; + + constructor(signer, domain) { + super(signer.provider); + this.#signer = signer; + this.#domain = domain; + } + + static from(signer, domain) { + return new this(signer, domain); + } + + get signingKey() { + return this.#signer.signingKey; + } + + get privateKey() { + return this.#signer.privateKey; + } + + async getAddress() { + return this.#signer.getAddress(); + } + + connect(provider) { + this.#signer.connect(provider); + } + + async signTransaction(tx) { + return this.#signer.signTransaction(tx); + } + + async signMessage(message) { + return this.#signer.signTypedData(this.#domain, { PersonalSign }, ERC4337Utils.preparePersonalSign(message)); + } + + async signTypedData(domain, types, value) { + const { allTypes, contentsTypeName, contentsDescr } = ERC4337Utils.getContentsDetail(types); + + return Promise.resolve( + this.#signer.signTypedData(domain, allTypes, ERC4337Utils.prepareSignTypedData(value, this.#domain)), + ).then(signature => + ethers.concat([ + signature, + ethers.TypedDataEncoder.hashDomain(domain), // appDomainSeparator + ethers.TypedDataEncoder.hashStruct(contentsTypeName, types, value), // contentsHash + ethers.toUtf8Bytes(contentsDescr), + ethers.toBeHex(contentsDescr.length, 2), + ]), + ); + } +} + +class ERC4337Utils { + static preparePersonalSign(message) { + return { + prefixed: ethers.concat([ + ethers.toUtf8Bytes(ethers.MessagePrefix), + ethers.toUtf8Bytes(String(message.length)), + typeof message === 'string' ? ethers.toUtf8Bytes(message) : message, + ]), + }; + } + + static prepareSignTypedData(contents, signerDomain) { + return { + name: signerDomain.name ?? '', + version: signerDomain.version ?? '', + chainId: signerDomain.chainId ?? 0, + verifyingContract: signerDomain.verifyingContract ?? ethers.ZeroAddress, + salt: signerDomain.salt ?? ethers.ZeroHash, + contents, + }; + } + + static getContentsDetail(contentsTypes, contentsTypeName = Object.keys(contentsTypes).at(0)) { + // Examples values + // + // contentsTypeName B + // typedDataSignType TypedDataSign(B contents,...)A(uint256 v)B(Z z)Z(A a) + // contentsType A(uint256 v)B(Z z)Z(A a) + // contentsDescr A(uint256 v)B(Z z)Z(A a)B + const allTypes = { TypedDataSign: TypedDataSign(contentsTypeName), ...contentsTypes }; + const typedDataSignType = ethers.TypedDataEncoder.from(allTypes).encodeType('TypedDataSign'); + const contentsType = typedDataSignType.slice(typedDataSignType.indexOf(')') + 1); // Remove TypedDataSign (first object) + const contentsDescr = contentsType + (contentsType.startsWith(contentsTypeName) ? '' : contentsTypeName); + + return { + allTypes, + contentsTypes, + contentsTypeName, + contentsDescr, + }; + } +} + +module.exports = { + ERC7739Signer, + ERC4337Utils, + PersonalSign, + TypedDataSign, +}; diff --git a/test/helpers/signers.js b/test/helpers/signers.js new file mode 100644 index 00000000000..d807d592f0d --- /dev/null +++ b/test/helpers/signers.js @@ -0,0 +1,147 @@ +const { + AbstractSigner, + Signature, + TypedDataEncoder, + assert, + assertArgument, + concat, + dataLength, + decodeBase64, + getBytes, + getBytesCopy, + hashMessage, + hexlify, + sha256, + toBeHex, +} = require('ethers'); +const { secp256r1 } = require('@noble/curves/p256'); +const { generateKeyPairSync, privateEncrypt } = require('crypto'); + +// Lightweight version of BaseWallet +class NonNativeSigner extends AbstractSigner { + #signingKey; + + constructor(privateKey, provider) { + super(provider); + assertArgument( + privateKey && typeof privateKey.sign === 'function', + 'invalid private key', + 'privateKey', + '[ REDACTED ]', + ); + this.#signingKey = privateKey; + } + + get signingKey() { + return this.#signingKey; + } + get privateKey() { + return this.signingKey.privateKey; + } + + async getAddress() { + throw new Error("NonNativeSigner doesn't have an address"); + } + + connect(provider) { + return new NonNativeSigner(this.#signingKey, provider); + } + + async signTransaction(/*tx: TransactionRequest*/) { + throw new Error('NonNativeSigner cannot send transactions'); + } + + async signMessage(message /*: string | Uint8Array*/) /*: Promise*/ { + return this.signingKey.sign(hashMessage(message)).serialized; + } + + async signTypedData( + domain /*: TypedDataDomain*/, + types /*: Record>*/, + value /*: Record*/, + ) /*: Promise*/ { + // Populate any ENS names + const populated = await TypedDataEncoder.resolveNames(domain, types, value, async name => { + assert(this.provider != null, 'cannot resolve ENS names without a provider', 'UNSUPPORTED_OPERATION', { + operation: 'resolveName', + info: { name }, + }); + const address = await this.provider.resolveName(name); + assert(address != null, 'unconfigured ENS name', 'UNCONFIGURED_NAME', { value: name }); + return address; + }); + + return this.signingKey.sign(TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized; + } +} + +class P256SigningKey { + #privateKey; + + constructor(privateKey) { + this.#privateKey = getBytes(privateKey); + } + + static random() { + return new this(secp256r1.utils.randomPrivateKey()); + } + + get privateKey() { + return hexlify(this.#privateKey); + } + + get publicKey() { + const publicKeyBytes = secp256r1.getPublicKey(this.#privateKey, false); + return { qx: hexlify(publicKeyBytes.slice(0x01, 0x21)), qy: hexlify(publicKeyBytes.slice(0x21, 0x41)) }; + } + + sign(digest /*: BytesLike*/) /*: Signature*/ { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + + const sig = secp256r1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), { lowS: true }); + + return Signature.from({ r: toBeHex(sig.r, 32), s: toBeHex(sig.s, 32), v: sig.recovery ? 0x1c : 0x1b }); + } +} + +class RSASigningKey { + #privateKey; + #publicKey; + + constructor(keyPair) { + const jwk = keyPair.publicKey.export({ format: 'jwk' }); + this.#privateKey = keyPair.privateKey; + this.#publicKey = { e: decodeBase64(jwk.e), n: decodeBase64(jwk.n) }; + } + + static random(modulusLength = 2048) { + return new this(generateKeyPairSync('rsa', { modulusLength })); + } + + get privateKey() { + return hexlify(this.#privateKey); + } + + get publicKey() { + return { e: hexlify(this.#publicKey.e), n: hexlify(this.#publicKey.n) }; + } + + sign(digest /*: BytesLike*/) /*: Signature*/ { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + // SHA256 OID = 608648016503040201 (9 bytes) | NULL = 0500 (2 bytes) (explicit) | OCTET_STRING length (0x20) = 0420 (2 bytes) + return { + serialized: hexlify( + privateEncrypt(this.#privateKey, getBytes(concat(['0x3031300d060960864801650304020105000420', digest]))), + ), + }; + } +} + +class RSASHA256SigningKey extends RSASigningKey { + sign(digest /*: BytesLike*/) /*: Signature*/ { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + return super.sign(sha256(getBytes(digest))); + } +} + +module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey }; diff --git a/test/metatx/ERC2771Forwarder.test.js b/test/metatx/ERC2771Forwarder.test.js index bf6cfd10c4b..07682c18747 100644 --- a/test/metatx/ERC2771Forwarder.test.js +++ b/test/metatx/ERC2771Forwarder.test.js @@ -174,7 +174,7 @@ describe('ERC2771Forwarder', function () { // Because the relayer call consumes gas until the `CALL` opcode, the gas left after failing // the subcall won't enough to finish the top level call (after testing), so we add a // moderated buffer. - const gasLimit = estimate + 2_000n; + const gasLimit = estimate + 10_000n; // The subcall out of gas should be caught by the contract and then bubbled up consuming // the available gas with an `invalid` opcode. diff --git a/test/utils/cryptography/ERC1271.behavior.js b/test/utils/cryptography/ERC1271.behavior.js new file mode 100644 index 00000000000..ef3e668028e --- /dev/null +++ b/test/utils/cryptography/ERC1271.behavior.js @@ -0,0 +1,111 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { Permit, formatType, getDomain } = require('../../helpers/eip712'); +const { ERC7739Signer } = require('../../helpers/erc7739'); + +function shouldBehaveLikeERC1271({ erc7739 = false } = {}) { + const MAGIC_VALUE = '0x1626ba7e'; + + describe(`supports ERC-${erc7739 ? 7739 : 1271}`, function () { + beforeEach(async function () { + // if deploy function is present, check that code is already in place + if (this.mock.deploy) { + await ethers.provider.getCode(this.mock.address).then(code => code != '0x' || this.mock.deploy()); + } + this._signer = erc7739 + ? new ERC7739Signer(this.signer, this.domain ?? (await getDomain(this.mock))) + : this.signer; + }); + + describe('PersonalSign', function () { + it('returns true for a valid personal signature', async function () { + const text = 'Hello, world!'; + + const hash = ethers.hashMessage(text); + const signature = await this._signer.signMessage(text); + + await expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); + }); + + it('returns false for an invalid personal signature', async function () { + const message = 'Message the app expects'; + const otherMessage = 'Message signed is different'; + + const hash = ethers.hashMessage(message); + const signature = await this._signer.signMessage(otherMessage); + + await expect(this.mock.isValidSignature(hash, signature)).to.eventually.not.equal(MAGIC_VALUE); + }); + }); + + describe('TypedDataSign', function () { + beforeEach(async function () { + // Dummy app domain, different from the ERC7739's domain + // Note the difference of format (signer domain doesn't include a salt, but app domain does) + this.appDomain = { + name: 'SomeApp', + version: '1', + chainId: await ethers.provider.getNetwork().then(({ chainId }) => chainId), + verifyingContract: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', + salt: '0x02cb3d8cb5e8928c9c6de41e935e16a4e28b2d54e7e7ba47e99f16071efab785', + }; + }); + + it('returns true for a valid typed data signature', async function () { + const contents = { + owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb', + spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f', + value: 1_000_000n, + nonce: 0n, + deadline: ethers.MaxUint256, + }; + + const hash = ethers.TypedDataEncoder.hash(this.appDomain, { Permit }, contents); + const signature = await this._signer.signTypedData(this.appDomain, { Permit }, contents); + + await expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); + }); + + it('returns true for valid typed data signature (nested types)', async function () { + const contentsTypes = { + B: formatType({ z: 'Z' }), + Z: formatType({ a: 'A' }), + A: formatType({ v: 'uint256' }), + }; + + const contents = { z: { a: { v: 1n } } }; + + const hash = ethers.TypedDataEncoder.hash(this.appDomain, contentsTypes, contents); + const signature = await this._signer.signTypedData(this.appDomain, contentsTypes, contents); + + await expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); + }); + + it('returns false for an invalid typed data signature', async function () { + const contents = { + owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb', + spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f', + value: 1_000_000n, + nonce: 0n, + deadline: ethers.MaxUint256, + }; + + const hash = ethers.TypedDataEncoder.hash(this.appDomain, { Permit }, contents); + // message signed by the user is for a lower amount. + const signature = await this._signer.signTypedData(this.appDomain, { Permit }, { ...contents, value: 1_000n }); + + await expect(this.mock.isValidSignature(hash, signature)).to.eventually.not.equal(MAGIC_VALUE); + }); + }); + + erc7739 && + it('support ERC-7739 detection', async function () { + const hash = '0x7739773977397739773977397739773977397739773977397739773977397739'; + await expect(this.mock.isValidSignature(hash, '0x')).to.eventually.equal('0x77390001'); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC1271, +}; diff --git a/test/utils/cryptography/ERC7739.test.js b/test/utils/cryptography/ERC7739.test.js new file mode 100644 index 00000000000..c7e9c009145 --- /dev/null +++ b/test/utils/cryptography/ERC7739.test.js @@ -0,0 +1,38 @@ +const { ethers } = require('hardhat'); +const { shouldBehaveLikeERC1271 } = require('./ERC1271.behavior'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../../helpers/signers'); + +describe('ERC7739', function () { + describe('for an ECDSA signer', function () { + before(async function () { + this.signer = ethers.Wallet.createRandom(); + this.mock = await ethers.deployContract('ERC7739ECDSAMock', [this.signer.address]); + }); + + shouldBehaveLikeERC1271({ erc7739: true }); + }); + + describe('for a P256 signer', function () { + before(async function () { + this.signer = new NonNativeSigner(P256SigningKey.random()); + this.mock = await ethers.deployContract('ERC7739P256Mock', [ + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]); + }); + + shouldBehaveLikeERC1271({ erc7739: true }); + }); + + describe('for an RSA signer', function () { + before(async function () { + this.signer = new NonNativeSigner(RSASHA256SigningKey.random()); + this.mock = await ethers.deployContract('ERC7739RSAMock', [ + this.signer.signingKey.publicKey.e, + this.signer.signingKey.publicKey.n, + ]); + }); + + shouldBehaveLikeERC1271({ erc7739: true }); + }); +}); diff --git a/test/utils/cryptography/ERC7739Utils.test.js b/test/utils/cryptography/ERC7739Utils.test.js new file mode 100644 index 00000000000..93e382df611 --- /dev/null +++ b/test/utils/cryptography/ERC7739Utils.test.js @@ -0,0 +1,203 @@ +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { Permit } = require('../../helpers/eip712'); +const { ERC4337Utils, PersonalSign } = require('../../helpers/erc7739'); + +const details = ERC4337Utils.getContentsDetail({ Permit }); + +const fixture = async () => { + const mock = await ethers.deployContract('$ERC7739Utils'); + const domain = { + name: 'SomeDomain', + version: '1', + chainId: await ethers.provider.getNetwork().then(({ chainId }) => chainId), + verifyingContract: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', + }; + const otherDomain = { + name: 'SomeOtherDomain', + version: '2', + chainId: await ethers.provider.getNetwork().then(({ chainId }) => chainId), + verifyingContract: '0x92C32cadBc39A15212505B5530aA765c441F306f', + }; + const permit = { + owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb', + spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f', + value: 1_000_000n, + nonce: 0n, + deadline: ethers.MaxUint256, + }; + return { mock, domain, otherDomain, permit }; +}; + +describe('ERC7739Utils', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('encodeTypedDataSig', function () { + it('wraps a typed data signature', async function () { + const signature = ethers.randomBytes(65); + const appSeparator = ethers.id('SomeApp'); + const contentsHash = ethers.id('SomeData'); + const contentsDescr = 'SomeType()'; + const encoded = ethers.concat([ + signature, + appSeparator, + contentsHash, + ethers.toUtf8Bytes(contentsDescr), + ethers.toBeHex(contentsDescr.length, 2), + ]); + + await expect( + this.mock.$encodeTypedDataSig(signature, appSeparator, contentsHash, contentsDescr), + ).to.eventually.equal(encoded); + }); + }); + + describe('decodeTypedDataSig', function () { + it('unwraps a typed data signature', async function () { + const signature = ethers.randomBytes(65); + const appSeparator = ethers.id('SomeApp'); + const contentsHash = ethers.id('SomeData'); + const contentsDescr = 'SomeType()'; + const encoded = ethers.concat([ + signature, + appSeparator, + contentsHash, + ethers.toUtf8Bytes(contentsDescr), + ethers.toBeHex(contentsDescr.length, 2), + ]); + + await expect(this.mock.$decodeTypedDataSig(encoded)).to.eventually.deep.equal([ + ethers.hexlify(signature), + appSeparator, + contentsHash, + contentsDescr, + ]); + }); + + it('returns default empty values if the signature is too short', async function () { + const encoded = ethers.randomBytes(65); // DOMAIN_SEPARATOR (32 bytes) + CONTENTS (32 bytes) + CONTENTS_TYPE_LENGTH (2 bytes) - 1 + await expect(this.mock.$decodeTypedDataSig(encoded)).to.eventually.deep.equal([ + '0x', + ethers.ZeroHash, + ethers.ZeroHash, + '', + ]); + }); + + it('returns default empty values if the length is invalid', async function () { + const encoded = ethers.concat([ethers.randomBytes(64), '0x3f']); // Can't be less than 64 bytes + await expect(this.mock.$decodeTypedDataSig(encoded)).to.eventually.deep.equal([ + '0x', + ethers.ZeroHash, + ethers.ZeroHash, + '', + ]); + }); + }); + + describe('personalSignStructhash', function () { + it('should produce a personal signature EIP-712 nested type', async function () { + const text = 'Hello, world!'; + + await expect(this.mock.$personalSignStructHash(ethers.hashMessage(text))).to.eventually.equal( + ethers.TypedDataEncoder.hashStruct('PersonalSign', { PersonalSign }, ERC4337Utils.preparePersonalSign(text)), + ); + }); + }); + + describe('typedDataSignStructHash', function () { + it('should match the typed data nested struct hash', async function () { + const message = ERC4337Utils.prepareSignTypedData(this.permit, this.domain); + + const contentsHash = ethers.TypedDataEncoder.hashStruct('Permit', { Permit }, this.permit); + const hash = ethers.TypedDataEncoder.hashStruct('TypedDataSign', details.allTypes, message); + + const domainBytes = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'bytes32', 'uint256', 'address', 'bytes32'], + [ + ethers.id(this.domain.name), + ethers.id(this.domain.version), + this.domain.chainId, + this.domain.verifyingContract, + ethers.ZeroHash, + ], + ); + + await expect( + this.mock.$typedDataSignStructHash( + details.contentsTypeName, + ethers.Typed.string(details.contentsDescr), + contentsHash, + domainBytes, + ), + ).to.eventually.equal(hash); + await expect( + this.mock.$typedDataSignStructHash(details.contentsDescr, contentsHash, domainBytes), + ).to.eventually.equal(hash); + }); + }); + + describe('typedDataSignTypehash', function () { + it('should match', async function () { + const typedDataSignType = ethers.TypedDataEncoder.from(details.allTypes).encodeType('TypedDataSign'); + + await expect( + this.mock.$typedDataSignTypehash( + details.contentsTypeName, + typedDataSignType.slice(typedDataSignType.indexOf(')') + 1), + ), + ).to.eventually.equal(ethers.keccak256(ethers.toUtf8Bytes(typedDataSignType))); + }); + }); + + describe('decodeContentsDescr', function () { + const forbiddenChars = ', )\x00'; + + for (const { descr, contentsDescr, contentTypeName, contentType } of [].concat( + { + descr: 'should parse a valid descriptor (implicit)', + contentsDescr: 'SomeType(address foo,uint256 bar)', + contentTypeName: 'SomeType', + }, + { + descr: 'should parse a valid descriptor (explicit)', + contentsDescr: 'A(C c)B(A a)C(uint256 v)B', + contentTypeName: 'B', + contentType: 'A(C c)B(A a)C(uint256 v)', + }, + { descr: 'should return nothing for an empty descriptor', contentsDescr: '', contentTypeName: null }, + { descr: 'should return nothing if no [(] is present', contentsDescr: 'SomeType', contentTypeName: null }, + { + descr: 'should return nothing if starts with [(] (implicit)', + contentsDescr: '(SomeType(address foo,uint256 bar)', + contentTypeName: null, + }, + { + descr: 'should return nothing if starts with [(] (explicit)', + contentsDescr: '(SomeType(address foo,uint256 bar)(SomeType', + contentTypeName: null, + }, + forbiddenChars.split('').map(char => ({ + descr: `should return nothing if contains [${char}] (implicit)`, + contentsDescr: `SomeType${char}(address foo,uint256 bar)`, + contentTypeName: null, + })), + forbiddenChars.split('').map(char => ({ + descr: `should return nothing if contains [${char}] (explicit)`, + contentsDescr: `SomeType${char}(address foo,uint256 bar)SomeType${char}`, + contentTypeName: null, + })), + )) { + it(descr, async function () { + await expect(this.mock.$decodeContentsDescr(contentsDescr)).to.eventually.deep.equal([ + contentTypeName ?? '', + contentTypeName ? (contentType ?? contentsDescr) : '', + ]); + }); + } + }); +}); diff --git a/test/utils/structs/EnumerableMap.test.js b/test/utils/structs/EnumerableMap.test.js index cb4b77a651f..d512fb32d18 100644 --- a/test/utils/structs/EnumerableMap.test.js +++ b/test/utils/structs/EnumerableMap.test.js @@ -3,17 +3,17 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { mapValues } = require('../../helpers/iterate'); const { generators } = require('../../helpers/random'); -const { TYPES, formatType } = require('../../../scripts/generate/templates/EnumerableMap.opts'); +const { MAP_TYPES, formatMapType } = require('../../../scripts/generate/templates/Enumerable.opts'); const { shouldBehaveLikeMap } = require('./EnumerableMap.behavior'); // Add Bytes32ToBytes32Map that must be tested but is not part of the generated types. -TYPES.unshift(formatType('bytes32', 'bytes32')); +MAP_TYPES.unshift(formatMapType('bytes32', 'bytes32')); async function fixture() { const mock = await ethers.deployContract('$EnumerableMap'); const env = Object.fromEntries( - TYPES.map(({ name, keyType, valueType }) => [ + MAP_TYPES.map(({ name, keyType, valueType }) => [ name, { keyType, @@ -52,7 +52,7 @@ describe('EnumerableMap', function () { Object.assign(this, await loadFixture(fixture)); }); - for (const { name } of TYPES) { + for (const { name } of MAP_TYPES) { describe(name, function () { beforeEach(async function () { Object.assign(this, this.env[name]); diff --git a/test/utils/structs/EnumerableMapExtended.test.js b/test/utils/structs/EnumerableMapExtended.test.js new file mode 100644 index 00000000000..a40b83dd12d --- /dev/null +++ b/test/utils/structs/EnumerableMapExtended.test.js @@ -0,0 +1,66 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { mapValues } = require('../../helpers/iterate'); +const { generators } = require('../../helpers/random'); +const { EXTENDED_MAP_TYPES } = require('../../../scripts/generate/templates/Enumerable.opts'); + +const { shouldBehaveLikeMap } = require('./EnumerableMap.behavior'); + +async function fixture() { + const mock = await ethers.deployContract('$EnumerableMapExtended'); + + const env = Object.fromEntries( + EXTENDED_MAP_TYPES.map(({ name, key, value }) => [ + name, + { + key, + value, + keys: Array.from({ length: 3 }, generators[key.type]), + values: Array.from({ length: 3 }, generators[value.type]), + zeroValue: generators[value.type].zero, + methods: mapValues( + { + set: `$set(uint256,${key.type},${value.type})`, + get: `$get(uint256,${key.type})`, + tryGet: `$tryGet(uint256,${key.type})`, + remove: `$remove(uint256,${key.type})`, + clear: `$clear_EnumerableMapExtended_${name}(uint256)`, + length: `$length_EnumerableMapExtended_${name}(uint256)`, + at: `$at_EnumerableMapExtended_${name}(uint256,uint256)`, + contains: `$contains(uint256,${key.type})`, + keys: `$keys_EnumerableMapExtended_${name}(uint256)`, + }, + fnSig => + (...args) => + mock.getFunction(fnSig)(0, ...args), + ), + events: { + setReturn: `return$set_EnumerableMapExtended_${name}_${key.type}_${value.type}`, + removeReturn: `return$remove_EnumerableMapExtended_${name}_${key.type}`, + }, + error: key.memory || value.memory ? `EnumerableMapNonexistent${key.name}Key` : `EnumerableMapNonexistentKey`, + }, + ]), + ); + + return { mock, env }; +} + +describe('EnumerableMapExtended', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + for (const { name, key, value } of EXTENDED_MAP_TYPES) { + describe(`${name} (enumerable map from ${key.type} to ${value.type})`, function () { + beforeEach(async function () { + Object.assign(this, this.env[name]); + [this.keyA, this.keyB, this.keyC] = this.keys; + [this.valueA, this.valueB, this.valueC] = this.values; + }); + + shouldBehaveLikeMap(); + }); + } +}); diff --git a/test/utils/structs/EnumerableSet.test.js b/test/utils/structs/EnumerableSet.test.js index 1f92727a4c4..f60adc103a5 100644 --- a/test/utils/structs/EnumerableSet.test.js +++ b/test/utils/structs/EnumerableSet.test.js @@ -3,7 +3,7 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { mapValues } = require('../../helpers/iterate'); const { generators } = require('../../helpers/random'); -const { TYPES } = require('../../../scripts/generate/templates/EnumerableSet.opts'); +const { SET_TYPES } = require('../../../scripts/generate/templates/Enumerable.opts'); const { shouldBehaveLikeSet } = require('./EnumerableSet.behavior'); @@ -20,7 +20,7 @@ async function fixture() { const mock = await ethers.deployContract('$EnumerableSet'); const env = Object.fromEntries( - TYPES.map(({ name, type }) => [ + SET_TYPES.map(({ name, type }) => [ type, { values: Array.from({ length: 3 }, generators[type]), @@ -49,7 +49,7 @@ describe('EnumerableSet', function () { Object.assign(this, await loadFixture(fixture)); }); - for (const { type } of TYPES) { + for (const { type } of SET_TYPES) { describe(type, function () { beforeEach(function () { Object.assign(this, this.env[type]); diff --git a/test/utils/structs/EnumerableSetExtended.test.js b/test/utils/structs/EnumerableSetExtended.test.js new file mode 100644 index 00000000000..3b9d5ad746d --- /dev/null +++ b/test/utils/structs/EnumerableSetExtended.test.js @@ -0,0 +1,62 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { mapValues } = require('../../helpers/iterate'); +const { generators } = require('../../helpers/random'); +const { EXTENDED_SET_TYPES } = require('../../../scripts/generate/templates/Enumerable.opts'); + +const { shouldBehaveLikeSet } = require('./EnumerableSet.behavior'); + +async function fixture() { + const mock = await ethers.deployContract('$EnumerableSetExtended'); + + const env = Object.fromEntries( + EXTENDED_SET_TYPES.map(({ name, value }) => [ + name, + { + value, + values: Array.from( + { length: 3 }, + value.size ? () => Array.from({ length: value.size }, generators[value.base]) : generators[value.type], + ), + methods: mapValues( + { + add: `$add(uint256,${value.type})`, + remove: `$remove(uint256,${value.type})`, + contains: `$contains(uint256,${value.type})`, + clear: `$clear_EnumerableSetExtended_${name}(uint256)`, + length: `$length_EnumerableSetExtended_${name}(uint256)`, + at: `$at_EnumerableSetExtended_${name}(uint256,uint256)`, + values: `$values_EnumerableSetExtended_${name}(uint256)`, + }, + fnSig => + (...args) => + mock.getFunction(fnSig)(0, ...args), + ), + events: { + addReturn: `return$add_EnumerableSetExtended_${name}_${value.type.replace(/[[\]]/g, '_')}`, + removeReturn: `return$remove_EnumerableSetExtended_${name}_${value.type.replace(/[[\]]/g, '_')}`, + }, + }, + ]), + ); + + return { mock, env }; +} + +describe('EnumerableSetExtended', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + for (const { name, value } of EXTENDED_SET_TYPES) { + describe(`${name} (enumerable set of ${value.type})`, function () { + beforeEach(function () { + Object.assign(this, this.env[name]); + [this.valueA, this.valueB, this.valueC] = this.values; + }); + + shouldBehaveLikeSet(); + }); + } +});