ERC-6900: Modular Smart Contract Accounts and Plugins

I’d like to discuss two issues and suggest a model that might solve them:

1. The use of DELEGATECALL.

While the latest version of the ERC doesn’t require DELEGATECALL, at least one operation still does:

The function updatePlugins takes in arrays of execution updates, hook updates, and hook group updates to perform. It also takes in an optional initialization function. The function MUST perform the update operation sequentially, then, if the address provided in init is not address(0) , MUST execute init with the calldata callData through a delegatecall .

Using DELEGATECALL for init is a risky default. We may need DELEGATECALL for some use cases but CALL should also be supported and used by default. Therefore an ExecutionMode should be specified for init.

2. The need to enforce permissions in the pre-validation hooks, based on calldata alone.

It is hard to enforce permissions in pre-validation functions if the subsequent function runs in the context of the account itself. Real enforcement must happen inline. An alternative is to run the functions themselves outside the account context and provide callbacks that enforce permissions.

Permissions model

Plugins have unlimited power, especially if they use DELEGATECALL. Permissions can’t really be enforced in pre-validation if the plugin operates in the context of the account itself.

This unstructured access This makes auditing and formal verification of plugin challenging. This might limit the growth of the plugins ecosystem.

I’d like to propose a permissions system loosely modeled after Android’s:

The plugin declares its requested permissions, as well as the components it has:

  • Initialization function and how it should be called (CALL/DELEGATECALL)
  • Hooks it needs to use and how they should be called (CALL/DELEGATECALL)
  • Execution functions it adds to the account.

It may also declare custom permissions defined by the plugin itself, which other plugins may request in order to interact with this plugin in certain ways.

This definition is similar to AndroidManifest.xml which every Android app includes. It should be returned by a pure function by the plugin, e.g. getManifest(), and the wallet would communicate it to the user before accepting it. The account will also use it for setting things up when adding/updating the plugin.

Here’s what the permissions might look like:

The default

By default (if the plugin doesn’t ask for any permissions), no DELEGATECALL, no special permissions in any account callbacks, the plugin is not trusted by the account beyond just calling the requested functions (CALL only). Some plugins may be able to work without any permissions.

For example, a spending-limit guard could be implemented using just a pre-execution hook which checks the current balance and returns it as a context, and a post-execution hook that reverts if the balance change is over the limit. Users will be able to add it with little risk because it won’t ask for any scary permissions.

DELEGATECALL permission

Requiring ongoing DELEGATECALL is the riskiest permission and should be avoided unless there is no other way to achieve the same functionality. This is equivalent to Android’s SYSTEM permission and would be used only by the most trusted plugins.

It might make sense for account devs to prevent users from adding such a plugin unless they explicitly whitelisted it, to prevent users from getting scammed. This is how Android handles apps that require SYSTEM - they must be signed by the manufacturer or by Google.

DELEGATECALL permission only during init

DELEGATECALL only during init is a bit safer since it’s easier to verify what it does, so it is considered a separate permission. But should also be avoided when possible. It might make sense to also treat it as a SYSTEM permission that requires whitelisting.

Specific storage permission

Specific-slot permissions can be given through setter/getter functions in the account, allowing plugins to access only certain slots. Similarly, the account may provide permissioned setters for certain mappings, arrays and structs. Specific-storage permission could allow a plugin to manage certain aspects of the account without giving up full control.

For example, a plugin that adds social recovery capability would need access to manage guardians and to set the account’s control key, but it doesn’t need access to manage modules or to transact on behalf of the account. It would ask for a PERMISSION.SET_KEY permission that allows it to call a callback that sets the key, and that’s it.

Per-plugin storage

A per-plugin getter/setter can be used for the plugin storage itself, providing unrestricted access to a mapping indexed by msg.sender. Each plugin has implicit permission to its own storage.

If the plugin is the only one that uses this mapping, it may be redundant since the plugin could use account-associated-storage in its own storage to store its data, but it will be useful for cross-plugin storage since it will be stored in the account itself and managed by the permissions system. Plugin1 may maintain a mapping in the account and define a PERMISSION.Plugin1.storage_read. Plugin2 will request that permission and be able to access that mapping as well. It enables splitting plugins to multiple modules with different permissions.

In the social recovery plugin example, there may be a complex module that manages the guardians and offers permissioned read access to the guardians. And then a very simple module that verifies guardian signatures according to the stored configuration, and sets the key. The complex module has no permissions (but declares a custom permission), and the simple module requests two permissions: PERMISSION.SET_KEY and PERMISSION.Plugin1.storage_read.

Execution from plugin

PERMISSION.execute_from_plugin, enabling calls to an executeFromPlugin function, similar to Safe’s executeFromModule. It allows the plugin to perform specific operations on behalf of the account without modifying its storage. E.g. send eth or transfer an ERC20 token.

Inside the account’s executeFromPlugin it is possible to enforce granular permissions for each plugin, e.g. the plugin can only transact with a specified contract. A plugin that create pre-execution hooks may declare custom permission such as PERMISSION.transact_with_contract_X and get called from executeFromPlugin before execution, where it’ll be able to check if the calling plugin has the permission to transact with contract X (or with all contracts). And a plugin that adds an execution function would only need to ask for the permission to transact with the contract it needs.

For example, a game plugin might ask for permission to transact with the game contract (and no other permissions). Users will be able to add it without worrying that it might steal assets.

Custom permissions

The account (or any of the plugins) may add custom permissions, similarly to how Android apps can create custom permissions. These permissions are not a part of the ERC but they are defined in the context of the account, the wallet knows how to interpret them to the user, and plugins may request these permissions when available.

Popular custom permissions may be standardized later if they are used by multiple account implementations and plugins.

PluginManifest

The model above requires the plugin to expose a structure, similar to AndroidManifest.xml to define the plugin’s required permissions, hooks with their execution modes, execution functions, etc. It should also have a protocol version to allow future upgrade, and a list of optional capabilities, e.g. declare that it only supports an account that implements a certain ERC beyond just 6900.

The manifest may also specify dependencies on other plugins. E.g. if PluginA adds a new execution function and PluginB uses it, then PluginB’s manifest would require PluginA. The wallet will detect this and install them together as needed (with the user’s approval).

The plugin’s getManifest() will return the manifest, RLP-encoded.

The update function could be replaced with an installation function that calls the plugin’s getManifest(). It’ll then install the plugin (unless it requires capabilities or a version that the account doesn’t have), grant the needed permissions, configure hooks, etc.

The wallet will first show the user the manifest and explain the meaning of the requested permissions. The user will be asked to sign a installPlugin(pluginAddress,manifestHash) transaction. The manifestHash is included to prevent the plugin from baiting and switching the requested permissions.

Example - social recovery plugin

Manager plugin doesn’t ask for permissions, only declares a permission and a few execution functions. All of them require the validation function ownerOnly which validates the owner’s key.

<manifest plugin="socialrecovery.manager" version:"1">
  <permission name="PERMISSION.socialrecovery.READ">
  <execution name="addGuardian(address)" validation="ownerOnly">
  <execution name="removeGuardian(address)" validation="ownerOnly">
  <execution name="setRecoveryPolicy(PolicyStruct)" validation="ownerOnly">
</manifest>

Recovery plugin asks for the SET_KEY permission. It also adds a validation function that validates the guardian signatures (so it needs the socialrecovery.READ permission defined by the manager module). And an execution function that sets the key, validated by the guardians validation function

<manifest plugin="socialrecovery.recovery" version:"1">
  <uses-permission name="PERMISSION.SET_KEY">
  <uses-permission name="PERMISSION.socialrecovery.READ">
  <validation name="validateGuardians">
  <execution name="setKeyByGuardians(bytes[])" validation="validateGuardians">
</manifest>

We’re not really going to use the XML format on-chain. It’ll be a more efficient RLP representation of this information. We should also optimize this model to better suit the ERC’s need. In this post I deliberately followed the Android model to show how it could work, but we can deviate from it when it makes sense.

Misc

Aside from the permission model and installation method, I’d like to propose another minor change:

In Calls to execution functions, If no corresponding plugin is found, the MSCA MUST revert. makes it incompatible with Safe fallback modules. Maybe we could consider supporting a “default plugin” with an associated validation function. Fallback modules built for Safe could then be compatible with this ERC with little or no changes.

8 Likes