\ The introduction of ERC-6900 marks a significant advancement in the Ethereum ecosystem, redefining how smart contracts can operate through Modular Smart Contract Accounts (MSCA). Building on the foundations of ERC-4337 (Account Abstraction), this new standard allows developers to enhance functionality by integrating additional logic and checks into external modules.
\ Key aspects of ERC-6900:
\ Important! Both standards (ERC-4337 and ERC-6900) are in draft stages, so changes are possible. This article covers AA (ERC-4337) version v0.6.0 and ERC-6900 (MSCA) version v0.7.0 (based on AA v0.6.0). For example, there is already a new version of AA with changes to validateUserOp, but MSCA does not yet support this.
\ Additionally, ERC-6900 is closely tied to Alchemy, so the latest updates on this standard are likely to be found in their repositories, as they are developing the architecture for working with such accounts. This is one of the main drawbacks of the standard—it is being created with the needs of a specific protocol in mind, rather than the entire community.
MSCAThe standard is inspired by ERC-2535: Diamonds, Multi-Facet Proxy for routing execution logic based on function selectors, although this standard is not directly used. All selectors are stored by the MSCA—essentially, it’s an extended version of the ERC-4337 account that includes logic for installing/removing plugins and knows which selector to use for redirecting calls.
\ The MSCA can handle two types of function calls:
To cover all possible calls (including direct ones), a plugin can have three types of callback functions:
\
\ The idea is to divide calls into two types due to their differences: calls from the EntryPoint and calls from EOAs and smart contracts. This distinction occurs at the validation level, while "shared" callback functions can be used at the execution level. The resulting structure is as follows:
\ There are also executeFromPlugin and executeFromPluginExternal calls, which are handled differently, but it’s better to start by understanding the first two types of calls and testing them in practice before trying to invoke one plugin from another.
How to Create an MSCA from an AATo convert a classic Account Abstraction (AA) wallet into an MSCA, four mandatory interfaces are required:
In addition to these mandatory interfaces, there is also IAccountLoupe.sol, which provides on-chain information about installed plugins. For example, it includes functions like getInstalledPlugins, getPreValidationHooks, and others.
PluginsA plugin is a singleton smart contract deployed as a single instance for all accounts that will install it. The plugin stores settings for each account. The smart contract of the plugin should not be upgradable; to update, the old version of the plugin must be removed and a new one installed.
\ The plugin should inherit from IPlugin.sol and implement at least the functions for installing and removing the plugin:
function onInstall(bytes calldata data) external; function onUninstall(bytes calldata data) external;\ The plugin must also contain a manifest and metadata.
function pluginManifest() external pure returns (PluginManifest memory); function pluginMetadata() external pure returns (PluginMetadata memory);\ The manifest is required for installing the plugin. It describes execution functions, validation functions, and hooks that will be configured on the MSCA during plugin installation. Additionally, the manifest contains dependency requirements (where a dependency can be another plugin) and permissions for using certain functions.
\ Let's take a closer look at the structure of the manifest.
Plugin ManifestThe manifest is a specification of the plugin that defines how the MSCA should interact with the plugin, which plugin functions should be called when certain selectors are accessed, and how to handle dependencies on other plugins.
struct PluginManifest { // List of ERC-165 interfaces to be added to the MSCA account. // Should not include the IPlugin interface bytes4[] interfaceIds; // If any plugin functions depend on validation through other plugins, // their interfaces should be added to this array bytes4[] dependencyInterfaceIds; // These are plugin functions that are set on the MSCA // and extend its functionality bytes4[] executionFunctions; // Functions already set on the MSCA that this plugin has access to bytes4[] permittedExecutionSelectors; // Flag indicating whether the plugin can call external smart contracts bool permitAnyExternalAddress; // Flag indicating whether the plugin can spend the network's native tokens bool canSpendNativeToken; // Function specifications ManifestExternalCallPermission[] permittedExternalCalls; ManifestAssociatedFunction[] userOpValidationFunctions; ManifestAssociatedFunction[] runtimeValidationFunctions; ManifestAssociatedFunction[] preUserOpValidationHooks; ManifestAssociatedFunction[] preRuntimeValidationHooks; ManifestExecutionHook[] executionHooks; }\
interfaceIdsFor example, if your account does not support working with ERC721 and cannot receive NFTs, you can add a plugin with the onERC721Received function and specify the IERC721Receiver interface in interfaceIds. As a result, the supportInterface function of the MSCA account will return true when checking for this interface.
dependencyInterfaceIdsThis should be specified when the target plugin depends on validation from another plugin. For example, let's take a look at the plugin I wrote for testing. Its main task is to check if an ERC20 token is in the whitelist when calling the transfer and approve functions. It has a utility function updateTokens, which adds and removes tokens from the whitelist. Naturally, access to this function should be restricted, but thousands of accounts might use the plugin, and it wouldn’t make sense to assign whitelist management to a single admin wallet.
\ Therefore, each MSCA account manages the list of tokens it can interact with independently. To ensure that only the MSCA has access to modify the whitelist, you need to add a dependency in the form of a plugin that will handle access checks. In my case, this is the MultiOwnerPlugin. The configuration of this check will be done next.
function pluginManifest() external pure override returns (PluginManifest memory) { PluginManifest memory manifest; // dependency manifest.dependencyInterfaceIds = new bytes4[](1); manifest.dependencyInterfaceIds[0] = type(IMultiOwnerPlugin).interfaceId; // ... } executionFunctionsThese are the functions that are set on the MSCA when the plugin is installed, thus extending its functionality. In my case, these are the updateTokens, isAllowedToken, and getTokens functions. "Extending" means that they, like the account's utility functions, will be called on the account "directly," for example, like this — account.updateTokens(). Since the plugin functions will be called through the account’s fallback function, if we do not add their selectors to executionFunctions, such a call will be rejected by the account.
permittedExecutionSelectorsThis array contains the function selectors that the plugin can call on the MSCA through the executeFromPlugin function.
permitAnyExternalAddressA flag that allows or prohibits calls via executeFromPluginExternal.
canSpendNativeTokenA flag that determines whether the plugin can use the network's native tokens.
Specification of Functions Used by the PluginThis section of the manifest describes how the plugin will interact with various functions and hooks in the MSCA.
permittedExternalCallsDefines permissions for external address calls. It has two options: either allow calls to any selector or provide an array of allowed selectors.
struct ManifestExternalCallPermission { address externalAddress; bool permitAnySelector; bytes4[] selectors; } userOpValidationFunctions, runtimeValidationFunctionsIf your plugin needs to connect during the userOpValidationFunction or runtimeValidationFunctions calls, you must specify for which selectors this will apply. It's important to note that the functions (userOpValidationFunctions and runtimeValidationFunctions) can only have one entry per account selector, regardless of the number of installed plugins.
\ For example, the MultiOwnerPlugin is responsible for validating all transactions when account selectors are called. This means that if MultiOwnerPlugin is already installed on the account, only it will handle validation of the IStandardExecutor.execute.selector in the userOpValidationFunctions and runtimeValidationFunctions. Therefore, it won't be possible to install another plugin that tries to validate the execute selector using these same functions, as it will result in an error like UserOpValidationFunctionAlreadySet(0xb61d27f6,0xc7183455a4c133ae270771860664b6b7ec320bb100).
\ The solution in such cases is to add the required validation in the preUserOpValidationHooks or preRuntimeValidationHooks hook, rather than in the function itself.
\ If you need to validate a new selector being installed on the MSCA, which is not handled by MultiOwnerPlugin, you can add it as a dependency. For this, the ManifestAssociatedFunction and ManifestFunction structures are used.
\ Let’s go through an example with my TokenWhitelistPlugin and the updateTokens function, which I want to add to the MSCA. Earlier, we already added IMultiOwnerPlugin to dependencyInterfaceIds—that was step 1. Now we need to add the selector to executionFunctions.
function pluginManifest() external pure override returns (PluginManifest memory) { PluginManifest memory manifest; // dependency manifest.dependencyInterfaceIds = new bytes4[](1); manifest.dependencyInterfaceIds[0] = type(IMultiOwnerPlugin).interfaceId; // runtime execution functions manifest.executionFunctions = new bytes4[](1); manifest.executionFunctions[0] = this.updateTokens.selector; // ... }\ This plugin function will only be called at runtime, because the account cannot call the plugin via userOp; therefore, we will handle the call through the appropriate function. First, we create a new ManifestAssociatedFunction array and add the function selector to it:
struct ManifestAssociatedFunction { bytes4 executionSelector; // account selector (in this case, added from the plugin) ManifestFunction associatedFunction; // function responsible for handling } manifest.runtimeValidationFunctions = new ManifestAssociatedFunction[](1); manifest.runtimeValidationFunctions[0] = ManifestAssociatedFunction({ executionSelector: this.updateTokens.selector, associatedFunction: // ... });\ Next, in associatedFunction, you need to add a ManifestFunction structure to describe what the plugin should do with this selector:
struct ManifestFunction { ManifestAssociatedFunctionType functionType; // This is an enum defined by the ERC-6900 standard uint8 functionId; // This is an enum defined in the plugin that helps identify the function uint256 dependencyIndex; // Dependency index in the dependencyInterfaceIds array }\ The first thing to determine is functionType, a flag that tells the account where to direct the processing of this selector:
enum ManifestAssociatedFunctionType { // Function not defined NONE, // Function belongs to this plugin SELF, // Function belongs to an external plugin provided // as a dependency during plugin installation. // Plugins MAY depend on external validation functions. // They MUST NOT depend on external hooks, otherwise installation will fail. DEPENDENCY, // Sets a magic value to always allow validation // in runtime mode for this function. // Only for runtime mode; if set for validationFunction, // it will be wasted gas. // If used with a hook, it is equivalent to having no hook set. RUNTIME_VALIDATION_ALWAYS_ALLOW, // Sets a magic value to always trigger a failure in the hook for this function. // Used only with pre-execution hooks. // Should not be used with validation functions— // equivalent to no validation. // It should not be used in post-exec hooks because if it is known // that the hook will always return, this should happen as early as possible // to save gas. PRE_HOOK_ALWAYS_DENY }\ In our case, the DEPENDENCY flag should be used. When a dependency is added, functionId does not matter, as it will be assigned by the dependency itself. dependencyIndex should correspond to the position of the dependency plugin in dependencyInterfaceIds.
manifest.runtimeValidationFunctions = new ManifestAssociatedFunction[](1); manifest.runtimeValidationFunctions[0] = ManifestAssociatedFunction({ executionSelector: this.updateTokens.selector, associatedFunction: ManifestFunction({ functionType: ManifestAssociatedFunctionType.DEPENDENCY, functionId: 0, // does not matter in the case of a dependency dependencyIndex: 0 // in our case, 0 because the IMultiOwnerPlugin interface is at this index }) });\ Almost done. For everything to work correctly, the TokenWhitelistPlugin needs to be installed with a description of the dependency and how it should function. The dependency is specified as bytes21, where the first 20 bytes contain the address of the dependency plugin, and the last byte indicates the functionId to be passed to the dependency.
// Custom type for packing plugin data type FunctionReference is bytes21; FunctionReference[] memory dependencies = new FunctionReference[](1); // specify the dependency address and functionId here dependencies[0] = FunctionReferenceLib.pack( address(multiOwnerPlugin), uint8(IMultiOwnerPlugin.FunctionId.RUNTIME_VALIDATION_OWNER_OR_SELF) ); vm.prank(owner); account1.installPlugin({ plugin: address(tokenWhitelistPlugin), manifestHash: manifestHash, pluginInstallData: tokenWhitelistPluginInstallData, dependencies: dependencies// pass dependencies here });\ Done. If everything is set up correctly, then when calling updateTokens on the account, the account will recognize that this selector is handled by the MultiOwnerPlugin dependency. It will therefore delegate control to its runtimeValidationFunction, where the functionId specified during dependency installation will be defined along with the plugin.
function runtimeValidationFunction( uint8 functionId, address sender, uint256, bytes calldata ) external view override { if (functionId == uint8(FunctionId.RUNTIME_VALIDATION_OWNER_OR_SELF)) { // Validate that the sender is an owner of the account, or self. if (sender != msg.sender && !isOwnerOf(msg.sender, sender)) { revert NotAuthorized(); } return; } revert NotImplemented(msg.sig, functionId); }\ Important! You cannot specify an external hook as a dependency—only an external function.
preUserOpValidationHooks, preRuntimeValidationHooksThese are also processed via ManifestAssociatedFunction. They are executed before the corresponding functions. There can be multiple hooks for a single selector within one MSCA account. They cannot be dependencies.
executionHooksHooks that are activated at the execution stage, regardless of how the call was made—whether through userOp or directly. For the TokenWhitelistPlugin, for example, this is a good place to capture all calls and check if the token is on the whitelist.
\ Since there are two execution-stage hooks: pre-execution and post-execution (preExecHook and postExecHook), the structure for describing hook handling is a bit different—ManifestExecutionHook:
struct ManifestExecutionHook { bytes4 selector; // function selector that needs to be checked during execution ManifestFunction preExecHook; // function for pre-execution check ManifestFunction postExecHook; // function for post-execution check }\ Next, we use the same ManifestFunction. For example, if I need to handle calls on the plugin through the MSCA account functions execute or executeBatch:
\ The manifest is ready. Next, the plugin needs to implement the preExecutionHook function to handle these calls. You can check out the implementation here.
function preExecutionHook( uint8 functionId, address sender, uint256 value, bytes calldata data ) external view override returns (bytes memory) {} Plugin DevelopmentBefore creating a plugin, it’s important to clearly define which selectors it will work with and where checks need to be performed. Here’s a rough checklist I’ve made for myself (there’s a chance I may have missed something):
\ Let’s go through this checklist with another plugin example — TransferLimitPlugin.
\ For clarity, I also sketched a diagram that can help with choosing the necessary functions or hooks for the plugin.
\ \ Next, we need to write the manifest according to the rules I mentioned above (it will be similar to the manifest for the TokenWhitelistPlugin plugin) and implement the functions themselves.
View and Pure FunctionsThe data-reading functions also need to be added to executionFunctions and assigned the type RUNTIME_VALIDATION_ALWAYS_ALLOW, so that they become part of the MSCA as well. However, in some cases, it may be simpler to call them directly on the plugin.
function pluginManifest() external pure override returns (PluginManifest memory) { // ... // runtime execution functions manifest.executionFunctions = new bytes4[](3); manifest.executionFunctions[0] = this.updateLimit.selector; manifest.executionFunctions[1] = this.getTokensForAccount.selector; manifest.executionFunctions[2] = this.getCurrentLimit.selector; ManifestFunction memory runtimeAlwaysAllow = ManifestFunction({ functionType: ManifestAssociatedFunctionType.RUNTIME_VALIDATION_ALWAYS_ALLOW, functionId: 0, dependencyIndex: 0 }); manifest.runtimeValidationFunctions = new ManifestAssociatedFunction[](3); manifest.runtimeValidationFunctions[0] = ManifestAssociatedFunction({ executionSelector: this.updateLimit.selector, // ... }); manifest.runtimeValidationFunctions[1] = ManifestAssociatedFunction({ executionSelector: this.getTokensForAccount.selector, associatedFunction: runtimeAlwaysAllow }); manifest.runtimeValidationFunctions[2] = ManifestAssociatedFunction({ executionSelector: this.getCurrentLimit.selector, associatedFunction: runtimeAlwaysAllow });You can see a complete example here.
Installing and Uninstalling the PluginTo allow the plugin to be installed on the MSCA, it’s necessary to implement the onInstall and onUninstall functions. For example, in the case of the TransferLimitPlugin, during installation, you can immediately set all necessary limits, and upon uninstallation — clear them.
function onInstall(bytes calldata data) external override { (ERC20SpendLimit[] memory spendLimits) = abi.decode(data, (ERC20SpendLimit[])); uint256 length = spendLimits.length; for (uint8 i = 0; i < length; i++) { _tokenList.tryAdd(msg.sender, SetValue.wrap(bytes30(bytes20(spendLimits[i].token)))); _limits[msg.sender][spendLimits[i].token] = spendLimits[i].limit; } } function onUninstall(bytes calldata data) external override { (address[] memory tokens) = abi.decode(data, (address[])); uint256 length = tokens.length; for (uint8 i = 0; i < length; i++) { delete _limits[msg.sender][tokens[i]]; } _tokenList.clear(msg.sender); }\ To install the plugin, you will need:
\ To uninstall the plugin, you'll need to configure UninstallPluginConfig
struct UninstallPluginConfig { // Plugin manifest bytes serializedManifest; // Flag to forcefully uninstall the plugin. // Removes the plugin even if the onUninstall function tries to stop the transaction bool forceUninstall; // Maximum amount of gas allowed for each uninstallation callback function // (`onUninstall`), or zero to set no limit. // Typically used together with `forceUninstall` to remove plugins that // prevent uninstallation by consuming all remaining gas. uint256 callbackGasLimit; }\ Example of plugin uninstallation:
function test_uninstallPlugin() external { // Retrieve the manifest bytes memory serializedManifest = abi.encode(transferLimitPlugin.pluginManifest()); // Configure the uninstallation settings bytes memory config = abi.encode( UpgradeableModularAccount.UninstallPluginConfig({ serializedManifest: serializedManifest, forceUninstall: false, callbackGasLimit: 0 }) ); // Prepare data for the onUninstall function address[] memory tokens = new address[](1); tokens[0] = address(token1); bytes memory uninstallData = abi.encode(tokens); // Uninstall the plugin vm.prank(owner); account1.uninstallPlugin({ plugin: address(transferLimitPlugin), config: config, pluginUninstallData: uninstallData }); } Plugin Features Order of Execution for Plugins on the Same SelectorsFor example, if you have two plugins, A and B, both of which handle the execute selector through hooks, in what order will they be called?
\ It’s logical to assume that plugins will be called in the order they were installed on the MSCA, from the "oldest" to the most recently installed. In reality, however, it works the opposite way.
\ Example: we added plugin A, then B, then C. Hooks will be called in the order C -> B -> A. All account hooks are stored in a linked list, and due to the way it’s processed, values are retrieved from the end. Additionally, if, for instance, plugin C's hook reverts the call, further hooks will not execute. This same behavior applies to the AccountLoupe::getInstalledPlugins function, which will return the array of plugins in reverse order of their installation.
Complexity of DevelopmentThe main difficulty lies not in writing the validation functions and hooks, but in ensuring that the plugin integrates well with the MSCA, doesn’t conflict with other plugins, and, in some cases, has the ability to interact and work in tandem with them.
TestingFor testing and getting familiar with the standard, you can use the plugin-template repository. It already has tests configured to work with EntryPoint.
ConclusionIn my opinion, the standard is somewhat ambiguous. On one hand, it offers solid opportunities for expanding the capabilities of an abstract account. On the other hand, it sometimes feels like it trips over itself, providing too many tools and ways to combine them. Perhaps it would have been better if this standard were more "lightweight." It’s also unfortunate that Alchemy and their protocol’s interests have such a strong influence on it. But undeniably, this is another step toward user-friendly abstract accounts.
Links\ We’ve built our own community to chat about the latest trends, share experiences, and discuss news in the web3 space. Come join us!
All Rights Reserved. Copyright , Central Coast Communications, Inc.