Minimal Modular Smart Accounts
Abstract
This proposal outlines the minimally required interfaces and behavior for modular smart accounts and modules to ensure interoperability across implementations. For accounts, the standard specifies execution, config and fallback interfaces as well as compliance to ERC-165 and ERC-1271. For modules, the standard specifies a core interface, module types and type-specific interfaces.
Motivation
Contract accounts are gaining adoption with many accounts being built using a modular architecture. These modular contract accounts (hereafter smart accounts) move functionality into external contracts (modules) in order to increase the speed and potential of innovation, to future-proof themselves and to allow customizability by developers and users. However, currently these smart accounts are built in vastly different ways, creating module fragmentation and vendor lock-in. There are several reasons for why standardizing smart accounts is very beneficial to the ecosystem, including:
- Interoperability for modules to be used across different smart accounts
- Interoperability for smart accounts to be used across different wallet applications and sdks
- Preventing significant vendor lock-in for smart account users
However, it is highly important that this standardization is done with minimal impact on the implementation logic of the accounts, so that smart account vendors can continue to innovate, while also allowing a flourishing, multi-account-compatible module ecosystem. As a result, the goal of this standard is to define the smart account and module interfaces and behavior that is as minimal as possible while ensuring interoperability between accounts and modules.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Definitions
- Smart account - A smart contract account that has a modular architecture.
- Module - A smart contract with self-contained smart account functionality.
- 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.
- Entrypoint - A trusted singleton contract according to ERC-4337 specifications.
Account
Validation
This standard does not dictate how validator selection is implemented. However, should a smart account encode validator selection mechanisms in data fields passed to the validator (e.g. in userOp.signature
if used with ERC-4337), the smart account MUST sanitize the affected values before invoking the validator.
The smart account’s validation function SHOULD return the return value of the validator.
Execution Behavior
To comply with this standard, smart accounts MUST implement the execution interface below:
interface IExecution {
/**
* @dev Executes a transaction on behalf of the account.
* @param mode The encoded execution mode of the transaction.
* @param executionCalldata The encoded execution call data.
*
* MUST ensure adequate authorization control: e.g. onlyEntryPointOrSelf if used with ERC-4337
* If a mode is requested that is not supported by the Account, it MUST revert
*/
function execute(bytes32 mode, bytes calldata executionCalldata) external;
/**
* @dev Executes a transaction on behalf of the account.
* This function is intended to be called by Executor Modules
* @param mode The encoded execution mode of the transaction.
* @param executionCalldata The encoded execution call data.
*
* MUST ensure adequate authorization control: i.e. onlyExecutorModule
* If a mode is requested that is not supported by the Account, it MUST revert
*/
function executeFromExecutor(bytes32 mode, bytes calldata executionCalldata)
external
returns (bytes[] memory returnData);
}
The account MAY also implement the following function in accordance with ERC-4337:
/**
* @dev ERC-4337 executeUserOp according to ERC-4337 v0.7
* This function is intended to be called by ERC-4337 EntryPoint.sol
* @param userOp PackedUserOperation struct (see ERC-4337 v0.7+)
* @param userOpHash The hash of the PackedUserOperation struct
*
* MUST ensure adequate authorization control: i.e. onlyEntryPoint
*/
function executeUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external;
The execution mode is a bytes32
value that is structured as follows:
- callType (1 byte):
0x00
for a singlecall
,0x01
for a batchcall
,0xfe
forstaticcall
and0xff
fordelegatecall
- execType (1 byte):
0x00
for executions that revert on failure,0x01
for executions that do not revert on failure but implement some form of error handling - unused (4 bytes): this range is reserved for future standardization
- modeSelector (4 bytes): an additional mode selector that can be used to create further execution modes
- modePayload (22 bytes): additional data to be passed
Here is a visual representation of the execution mode:
CallType | ExecType | Unused | ModeSelector | ModePayload |
---|---|---|---|---|
1 byte | 1 byte | 4 bytes | 4 bytes | 22 bytes |
Accounts are NOT REQUIRED to implement all execution modes. The account MUST declare what modes are supported in supportsExecutionMode
(see below) and if a mode is requested that is not supported by the account, the account MUST revert.
The account MUST encode the execution data the following ways:
- For single calls, the
target
,value
andcallData
are packed in this order (ieabi.encodePacked
in Solidity). - For delegatecalls, the
target
andcallData
are packed in this order (ieabi.encodePacked
in Solidity). - For batch calls, the
targets
,values
andcallDatas
are put into an array ofExecution
structs that includes these fields in this order (ieExecution(address target, uint256 value, bytes memory callData)
). Then, this array is encoded with padding (ieabi.encode
in Solidity).
Account configurations
To comply with this standard, smart accounts MUST implement the account config interface below:
interface IAccountConfig {
/**
* @dev Returns the account id of the smart account
* @return accountImplementationId the account id of the smart account
*
* MUST return a non-empty string
* The accountId SHOULD be structured like so:
* "vendorname.accountname.semver"
* The id SHOULD be unique across all smart accounts
*/
function accountId() external view returns (string memory accountImplementationId);
/**
* @dev Function to check if the account supports a certain execution mode (see above)
* @param encodedMode the encoded mode
*
* MUST return true if the account supports the mode and false otherwise
*/
function supportsExecutionMode(bytes32 encodedMode) external view returns (bool);
/**
* @dev Function to check if the account supports a certain module typeId
* @param moduleTypeId the module type ID according to the ERC-7579 spec
*
* MUST return true if the account supports the module type and false otherwise
*/
function supportsModule(uint256 moduleTypeId) external view returns (bool);
}
Module configurations
To comply with this standard, smart accounts MUST implement the module config interface below.
When storing an installed module, the smart account MUST ensure that there is a way to differentiate between module types. For example, the smart account should be able to implement access control that only allows installed executors, but not other installed modules, to call the executeFromExecutor
function.
interface IModuleConfig {
event ModuleInstalled(uint256 moduleTypeId, address module);
event ModuleUninstalled(uint256 moduleTypeId, address module);
/**
* @dev Installs a Module of a certain type on the smart account
* @param moduleTypeId the module type ID according to the ERC-7579 spec
* @param module the module address
* @param initData arbitrary data that may be required on the module during `onInstall`
* initialization.
*
* MUST implement authorization control
* MUST call `onInstall` on the module with the `initData` parameter if provided
* MUST emit ModuleInstalled event
* MUST revert if the module is already installed or the initialization on the module failed
*/
function installModule(uint256 moduleTypeId, address module, bytes calldata initData) external;
/**
* @dev Uninstalls a Module of a certain type on the smart account
* @param moduleTypeId the module type ID according the ERC-7579 spec
* @param module the module address
* @param deInitData arbitrary data that may be required on the module during `onInstall`
* initialization.
*
* MUST implement authorization control
* MUST call `onUninstall` on the module with the `deInitData` parameter if provided
* MUST emit ModuleUninstalled event
* MUST revert if the module is not installed or the deInitialization on the module failed
*/
function uninstallModule(uint256 moduleTypeId, address module, bytes calldata deInitData) external;
/**
* @dev Returns whether a module is installed on the smart account
* @param moduleTypeId the module type ID according the ERC-7579 spec
* @param module the module address
* @param additionalContext arbitrary data that may be required to determine if the module is installed
*
* MUST return true if the module is installed and false otherwise
*/
function isModuleInstalled(uint256 moduleTypeId, address module, bytes calldata additionalContext) external view returns (bool);
}
Hooks
Hooks are an OPTIONAL extension of this standard. Smart accounts MAY use hooks to execute custom logic and checks before and/or after the smart accounts performs a single or batched execution. To comply with this OPTIONAL extension, a smart account:
- MUST call the
preCheck
function of one or multiple hooks during an execution on the account - MUST call the
postCheck
function of one or multiple hooks during an execution on the account
ERC-1271 Forwarding
The smart account MUST implement the ERC-1271 interface. The isValidSignature
function calls MAY be forwarded to a validator. If ERC-1271 forwarding is implemented, the validator MUST be called with isValidSignatureWithSender(address sender, bytes32 hash, bytes signature)
, where the sender is the msg.sender
of the call to the smart account. Should the smart account implement any validator selection encoding in the bytes signature
parameter, the smart account MUST sanitize the parameter, before forwarding it to the validator.
The smart account’s ERC-1271 isValidSignature
function SHOULD return the return value of the validator that the request was forwarded to.
Fallback
Smart accounts MAY implement a fallback function that forwards the call to a fallback handler.
If the smart account has a fallback handler installed, it:
- MUST use
call
orstaticcall
to invoke the fallback handler - MUST utilize ERC-2771 to add the original
msg.sender
to thecalldata
sent to the fallback handler - MUST route to fallback handlers based on the function selector of the calldata
- MAY implement authorization control, which SHOULD be done via hooks
ERC-165
Smart accounts MUST implement ERC-165. However, for every interface function that reverts instead of implementing the functionality, the smart account MUST return false
for the corresponding interface id.
Modules
This standard separates modules into the following different types that each has a unique and incremental identifier, which MUST be used by accounts, modules and other entities to identify the module type:
- Validation (type id: 1)
- Execution (type id: 2)
- Fallback (type id: 3)
- Hooks (type id: 4)
Note: A single module can be of multiple types.
Modules MUST implement the following interface:
interface IModule {
/**
* @dev This function is called by the smart account during installation of the module
* @param data arbitrary data that may be required on the module during `onInstall` initialization
*
* MUST revert on error (e.g. if module is already enabled)
*/
function onInstall(bytes calldata data) external;
/**
* @dev This function is called by the smart account during uninstallation of the module
* @param data arbitrary data that may be required on the module during `onUninstall` de-initialization
*
* MUST revert on error
*/
function onUninstall(bytes calldata data) external;
/**
* @dev Returns boolean value if module is a certain type
* @param moduleTypeId the module type ID according the ERC-7579 spec
*
* MUST return true if the module is of the given type and false otherwise
*/
function isModuleType(uint256 moduleTypeId) external view returns(bool);
}
Validators
Validators MUST implement the IModule
and the IValidator
interface and have module type id: 1
.
interface IValidator is IModule {
/**
* @dev Validates a UserOperation
* @param userOp the ERC-4337 PackedUserOperation
* @param userOpHash the hash of the ERC-4337 PackedUserOperation
*
* MUST validate that the signature is a valid signature of the userOpHash
* SHOULD return ERC-4337's SIG_VALIDATION_FAILED (and not revert) on signature mismatch
*/
function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external returns (uint256);
/**
* @dev Validates a signature using ERC-1271
* @param sender the address that sent the ERC-1271 request to the smart account
* @param hash the hash of the ERC-1271 request
* @param signature the signature of the ERC-1271 request
*
* MUST return the ERC-1271 `MAGIC_VALUE` if the signature is valid
* MUST NOT modify state
*/
function isValidSignatureWithSender(address sender, bytes32 hash, bytes calldata signature) external view returns (bytes4);
}
Executors
Executors MUST implement the IModule
interface and have module type id: 2
.
Fallback Handlers
Fallback handlers MUST implement the IModule
interface and have module type id: 3
.
Fallback handlers MAY implement authorization control. Fallback handlers that do implement authorization control, MUST NOT rely on msg.sender
for authorization control but MUST use ERC-2771 _msgSender()
instead.
Hooks
Hooks MUST implement the IModule
and the IHook
interface and have module type id: 4
.
interface IHook is IModule {
/**
* @dev Called by the smart account before execution
* @param msgSender the address that called the smart account
* @param value the value that was sent to the smart account
* @param msgData the data that was sent to the smart account
*
* MAY return arbitrary data in the `hookData` return value
*/
function preCheck(address msgSender, uint256 value, bytes calldata msgData) external returns (bytes memory hookData);
/**
* @dev Called by the smart account after execution
* @param hookData the data that was returned by the `preCheck` function
*
* MAY validate the `hookData` to validate transaction context of the `preCheck` function
*/
function postCheck(bytes calldata hookData) external;
}
Rationale
Minimal approach
Smart accounts are a new concept and we are still learning about the best ways to build them. Therefore, we should not be too opinionated about how they are built. Instead, we should define the most minimal interfaces that allow for interoperability between smart accounts and modules to be used across different account implementations.
Our approach has been twofold:
- Take learnings from existing smart accounts that have been used in production and from building interoperability layers between them
- Ensure that the interfaces are as minimal and open to alternative architectures as possible
Extensions
While we want to be minimal, we also want to allow for innovation and opinionated features. Some of these features might also need to be standardized (for similar reasons as the core interfaces) even if not all smart accounts will implement them. To ensure that this is possible, we suggest for future standardization efforts to be done as extensions to this standard. This means that the core interfaces will not change, but that new interfaces can be added as extensions. These should be proposed as separate ERCs, for example with the title [FEATURE] Extension for ERC-7579
.
Specification
Execution mode
Accounts need to be able to execute calldata in different ways. Rather than defining a separate function for each combination of execution types, we decided to encode the execution type in a single bytes32
value. This allows for a more flexible and extensible approach, while also making the code far easier to write, read, maintain and audit. As explained above, the exeuction mode consists of two bytes that encode the call type and the execution type. The call type covers the three different methods of calls, namely single, batched and delegatecall
(note that you can delegatecall
to a multicall contract to batch delegatecalls
). The execution type covers the two different types of executions, namely executions that revert on failure and executions that do not revert on failure but implement some form of error handling. This allows for accounts to batch together uncorrelated executions, such that if one execution fails, the other executions can still be executed. These two bytes are followed by 4 unused bytes that are reserved for futurre standardization, should this be required. This is followed by an item of 4 bytes which is a custom mode selector that accounts can implement. This allows for accounts to implement custom execution modes that are not covered by the standard and do not need to be standardized. This item is 4 bytes long to ensure collision resistance between different account vendors, with the same guarantees as Solidity function selectors. Finally, the last 22 bytes are reserved for custom data that can be passed to the account. This allows for accounts to pass any data up to 22 bytes, such as a 2 byte flag followed by an address, or otherwise a pointer to further data packed into the calldata for the execution. For example, this payload can be used to pass a hook address that should be executed before and/or after the execution.
Differentiating module types
Not differentiating between module types could present a security issue when enforcing authorization control. For example, if a smart account treats validators and executors as the same type of module, it could allow a validator to execute arbitrary transactions on behalf of the smart account.
Account id
The account config interface includes a function accountId
which can be used to identify an account. This is especially useful for frontend libraries that need to determine what account type and version is being used in order to implement the correct logic for account behavior that is not standardized. Alternate solutions include using an ERC-165-like interface to declare the exact differences and supported features of accounts or returning a keccak hash of the account id. However, the first solution is not as flexible as the account id and requires agreeing on a well-defined set of features to use, while the second solution is not as human-readable as the account id.
Dependence on ERC-4337
This standard has a strict dependency on ERC-4337 for the validation flow. However, it is likely that smart account builders will want to build modular accounts in the future that do not use ERC-4337 but, for example, a native account abstraction implementation on a rollup. Once this starts to happen, the proposed upgrade path for this standard is to move the ERC-4337 dependency into an extension (ie a separate ERC) and to make it optional for smart accounts to implement. If it is required to standardize the validation flow for different account abstraction implementations, then these requirements could also be moved into separate extensions.
The reason this is not done from the start is that currently, the only modular accounts that are being built are using ERC-4337. Therefore, it makes sense to standardize the interfaces for these accounts first and to move the ERC-4337 dependency into an extension once there is a need for it. This is to maximize learnings about how modular accounts would look like when built on different account abstraction implementations.
Backwards Compatibility
Already deployed smart accounts
Smart accounts that have already been deployed will most likely be able to implement this standard. If they are deployed as proxies, it is possible to upgrade to a new account implementation that is compliant with this standard. If they are deployed as non-upgradeable contracts, it might still be possible to become compliant, for example by adding a compliant adapter as a fallback handler, if this is supported.
Reference Implementation
A full interface of a smart account can be found in IMSA.sol
.
Security Considerations
Needs more discussion. Some initial considerations:
- Implementing
delegatecall
executions on a smart account must be considered carefully. Note that smart accounts implementingdelegatecall
must ensure that the target contract is safe, otherwise security vulnerabilities are to be expected. - The
onInstall
andonUninstall
functions on modules may lead to unexpected callbacks (e.g. reentrancy). Account implementations should consider this by implementing adequate protection routines. Furthermore, modules could maliciously revert ononUninstall
to stop the account from uninstalling a module and removing it from the account. - For modules types where only a single module is active at one time (e.g. fallback handlers), calling
installModule
on a new module will not properly uninstall the previous module, unless this is properly implemented. This could lead to unexpected behavior if the old module is then added again with left over state. - Insufficient authorization control in fallback handlers can lead to unauthorized executions.
- Malicious Hooks may revert on
preCheck
orpostCheck
, adding untrusted hooks may lead to a denial of service of the account. - Currently account configuration functions (e.g.
installModule
) are designed for single operations. An account could allow these to be called fromaddress(this)
, creating the possibility to batch configuration operations. However, if an account implements greater authorization control for these functions since they are more sensitive, then these measures can be bypassed by nesting calls to configuration options in calls to self.
Copyright
Copyright and related rights waived via CC0.