Add Account framework (#5657)

This commit is contained in:
Ernesto García
2025-06-02 08:22:57 -06:00
committed by GitHub
parent 88962fb5ab
commit 83d2a247be
38 changed files with 3086 additions and 46 deletions

View File

@ -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.

View File

@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---
`Account`: Added a simple ERC-4337 account implementation with minimal logic to process user operations.

View File

@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---
`SignerERC7702`: Implementation of `AbstractSigner` for Externally Owned Accounts (EOAs). Useful with ERC-7702.

View File

@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---
`IERC7821`, `ERC7821`: Interface and logic for minimal batch execution. No support for additional `opData` is included.

View File

@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---
`AccountERC7579Hooked`: Extension of `AccountERC7579` that implements support for ERC-7579 hook modules.

View File

@ -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.

View File

@ -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 {}
}

View File

@ -1,9 +1,27 @@
= Account
[.readme-notice]
NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/api/account
This directory includes contracts to build accounts for ERC-4337.
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}}
{{ERC7821}}
== Utilities

View File

@ -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:
*
* ```
* <module address (20 bytes)> | <key (4 bytes)> | <nonce (8 bytes)>
* ```
* 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:
*
* ```
* <module address (20 bytes)> | <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;
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,43 @@
// 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.
*
* For calldata compression efficiency, if a Call.to is `address(0)`,
* it will be replaced with `address(this)`.
*/
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);
}

View File

@ -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 {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -5,24 +5,24 @@ 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 {AbstractSigner} from "../../../utils/cryptography/AbstractSigner.sol";
contract ERC7739ECDSAMock is AbstractSigner, ERC7739 {
address private _signer;
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") {
_signer = signerAddr;
}
function signer() public view virtual returns (address) {
return _signer;
}
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;
_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);
}
}

View File

@ -46,10 +46,12 @@ 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.
* {Time}: A library that provides helpers for manipulating time-related objects, including a `Delay` type.
* {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.
* {Time}: A library that provides helpers for manipulating time-related objects, including a `Delay` type.
* {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.
[NOTE]
====
@ -90,6 +92,14 @@ Because Solidity does not support generic types, {EnumerableMap} and {Enumerable
{{AbstractSigner}}
{{SignerECDSA}}
{{SignerP256}}
{{SignerERC7702}}
{{SignerRSA}}
== Security
{{ReentrancyGuard}}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -334,6 +334,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

View File

@ -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 };

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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")));
}
}

View File

@ -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 });
});

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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 });
});
});

View File

@ -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,
};

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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,
};

View File

@ -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,
);

View File

@ -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,
};

147
test/helpers/signers.js Normal file
View File

@ -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<string>*/ {
return this.signingKey.sign(hashMessage(message)).serialized;
}
async signTypedData(
domain /*: TypedDataDomain*/,
types /*: Record<string, Array<TypedDataField>>*/,
value /*: Record<string, any>*/,
) /*: Promise<string>*/ {
// 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 };

View File

@ -1,5 +1,6 @@
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 () {
@ -10,4 +11,28 @@ describe('ERC7739', function () {
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 });
});
});