Destiner's Notes

ERC-7579 Executors with ModuleKit

29 October 2024

Code is available on GitHub

In the previous post, we went through the process of designing and developing a validator module. Here, we will tackle executors. Specifically, we will write an executor module that enables automatic renewals of ENS domains.

Motivation

Executors let you codify a certain behaviour through a smart contract. When an executor is installed on an account, it adds a “specific” feature that has access to that account. An executor would have access to the external state, meaning that you can “react” to certain events and conditions. Optionally, you can limit access to certain addresses based on any onchain data (e.g. a fixed allowlist, token holders, DAO members, and more).

Note that you still need somebody to execute it. It can be an EOA, a bot, or even another smart account. The beauty of executors is that they are permissionless by nature: you don’t need any off-chain entity to make them work.

Ultimately, executors are onchain automation tools. They let anyone codify a certain behaviour on any account that “enables” (installs) it.

There will be a few general-purpose executors, like scheduled token transfers or DCA. However, I believe that the majority of modules will be protocol-specific.

I deeply believe that most protocol-specific executors will be created by the protocol teams themselves:

How does making an executor will increase usage? It’s the same reason why subscriptions reduce churn compared to manual extensions. Automation means less manual input, which means less cognitive load.

In a world where everyone has a modular smart account, an executor module can be a great gateway to your protocol.

So if you’re already working on a protocol, you’re well-positioned to make an executor or two based on it.

Configuration

As with validators, a great way of starting with the executor is to map out the module state.

We will be working on an ENS renewal module. Roughly, there are three things to decide on when you want to renew your domains: which domains to renew, when to do it, and what the renewal duration should be.

I believe that foundational modules like this should be as unopinionated as possible. Every module goes through a long cycle of development, testing, and audit, so it’s usually best to make it as flexible as possible from the beginning. As with the validation modules, it’s up to the end-user UIs to take the input from the user and provide sane defaults where it makes sense.

After some consideration, I decided to make the renewal duration and the renewal threshold both configured as uint256 values. They are unlikely to be exposed to the end users, at least not as part of the happy path, but the apps can decide the optimal values based on their use cases and target audience.

As for deciding what domains to renew, we want to be very flexible. The user will be able to set either an allowlist or a denylist, but both will be optional. If neither are set, every domain owned by the account can be renewed, offering a simple “set and forget” workflow.

Usually, the installation data gets set as the module’s config verbatim, but here I will do it a bit differently to optimize. You see, it’s better to store allowlist and denylist as mappings for cheap access, but we can’t pass it that way in a calldata. Also, there’s no way to tell whether a mapping is empty or not, so we will have to add separate values to track that.

Our installation data can look something like this:

struct InstallData {
    uint256 threshold;
    uint256 renewalDuration;
    string[] allowlist;
    string[] denylist;
}

Our config will store the same data, but in a different way:

struct Config {
    uint256 threshold;
    uint256 renewalDuration;
    uint256 iteration;
    bool allowlistEnabled;
    bool denylistEnabled;
}

We can’t store a mapping inside a struct, so we will have to store allowlist and denylist as separate variables:

mapping(address account => mapping(uint256 iteration => mapping(uint256 id => bool))) public
    allowlist;
mapping(address account => mapping(uint256 iteration => mapping(uint256 id => bool))) public
    denylist;

We need iteration here to remove the data. In Solidity, it’s not possible to clean up a mapping’s value recursively, so instead we will bump the iteration each time the module is deleted.

The installation hook will validate the input and convert it to the proper format:

function onInstall(bytes calldata data) external override {
    InstallData memory installData = abi.decode(data, (InstallData));

    // validate the input
    if (installData.threshold == 0) {
        revert InvalidThreshold();
    }
    if (installData.renewalDuration == 0) {
        revert InvalidDuration();
    }
    if (installData.allowlist.length > 0 && installData.denylist.length > 0) {
        revert BothAllowAndDenylist();
    }

    Config storage $config = accountConfig[msg.sender];
    $config.threshold = installData.threshold;
    $config.renewalDuration = installData.renewalDuration;
    $config.allowlistEnabled = installData.allowlist.length > 0;
    $config.denylistEnabled = installData.denylist.length > 0;

    if ($config.allowlistEnabled) {
        for (uint256 i = 0; i < installData.allowlist.length; i++) {
            allowlist[msg.sender][$config.iteration][_getNameId(installData.allowlist[i])] =
                true;
        }
    }
    if ($config.denylistEnabled) {
        for (uint256 i = 0; i < installData.denylist.length; i++) {
            denylist[msg.sender][$config.iteration][_getNameId(installData.denylist[i])] = true;
        }
    }
}

Uninstallation hook will clean up what’s possible and bump the iteration number to “update” the mapping values:

function onUninstall(bytes calldata data) external override {
    // cache the account
    address account = msg.sender;
    // get storage reference to account config
    Config storage $config = accountConfig[account];

    // increment the iteration number
    uint256 _newIteration = $config.iteration + 1;
    $config.iteration = uint128(_newIteration);

    // delete non-mapping config
    delete $config.threshold;
    delete $config.renewalDuration;
    delete $config.allowlistEnabled;
    delete $config.denylistEnabled;
}

Renewal flow

The function will take a name (or a list of names), as well as the account address:

function renew(address account, string calldata ensName) external {
    // …
}

function renewMany(address account, string[] calldata ensNames) external {
    // …
}

When the renewal function is called, we will first need to make sure that the module is initialized for that account.

if (!isInitialized(account)) revert NotInitialized(account);

All the subsequent logic will be implemented in a shared internal function:

function _renew(address account, string calldata ensName) internal {
    Config memory config = accountConfig[account];

    // …
}

ENS uses names in some contracts and tokenId on others, while it ultimately refers to the same thing (an ENS name). We will need both. Thankfully, the (one-way) conversion is straightforward:

function _renew(address account, string calldata ensName) internal {
    uint256 tokenId = _getNameId(ensName);
}

function _getNameId(string memory ensName) internal pure returns (uint256) {
    return uint256(keccak256(bytes(ensName)));
}

Remember that anyone can call the renewal function, so we need to make proper checks.

Let’s verify the validity of the renewal action against the account’s config.

// Check that the domain is owned by the account
address owner = ensBaseRegistrar.ownerOf(tokenId);
require(owner == account, InvalidOwner());

// Check that the remaining time is less than the renewal threshold
uint256 expiration = ensBaseRegistrar.nameExpires(tokenId);
require(expiration != 0, InvalidExpiration());
require(expiration < block.timestamp + config.renewalDuration, InvalidExpiration());

// Check that the domain is not in the denylist
if (config.denylistEnabled) {
    mapping(uint256 => bool) storage accountDenylist = denylist[account][config.iteration];
    require(!accountDenylist[tokenId], InDenylist());
}
// Check that the domain is in the allowlist (if not empty)
if (config.allowlistEnabled) {
    mapping(uint256 => bool) storage accountAllowlist = allowlist[account][config.iteration];
    require(accountAllowlist[tokenId], NotInAllowlist());
}

Finally, we can do the actual renewal.

ENS Integration

As I said before, ideally you know the protocol you’re integrating well. There might be lots of quirks and intricacies. Deeply understanding the details is key to making a secure module.

Executors are powerful! They have sudo-like access to the account. Write them carefully.

ModuleKit offers ready-made integrations for popular standards and protocols, including ERC20/ERC721 tokens, Uniswap, and more. It doesn’t include ENS yet, so we will have to write our own connector.

First, we will query the registrar contract to fetch the renewal price:

IENSETHPriceOracle.Price memory price =
    ensETHRegistrarController.rentPrice(ensName, config.renewalDuration);
uint256 renewalPrice = price.base + price.premium;

Then we can call the renew function of the registrar controller to renew the domain:

// Renew the domain based on the renewal duration
bytes memory data = abi.encodeWithSelector(
    ensETHRegistrarController.renew.selector, ensName, config.renewalDuration
);
_execute(account, address(ensETHRegistrarController), renewalPrice, data);

Notice the usage of _execute internal function. This function “passes” a low-level call to the account contract, which then executes the renew call from its own context and using its own balance.

_execute allows you to utilize batching and delegate calls if you need that. Not every account will support those call types, though, and you should use supportsExecutionMode before making such calls. In our case though, we don’t need anything outside a basic external call.

Autonomous Execution

Here comes the beauty of executors. Since we didn’t define any allowlists or gating rules, anyone can run it. The module is permissionless on the smart contract level.

A good question is why anyone would execute it. Making onchain transactions requires gas. ENS Labs is working on an L2 solution, and they might be incentivised to cover gas costs there and do it themselves to increase network adoption.

However, we are looking for a general-purpose solution that we can utilize today. The easiest way to achieve that is to use an off-chain service like [https://docs.rhinestone.wtf/automations](Rhinestone Automations).

I’m personally excited about having an incentivised network of keepers, where you don’t need to rely on centralized services and instead can delegate executions to onchain agents.

Testing

I’ve shared some testing tips in the previous article, and most of it applies here as well.

To add some insights, I’ve found it’s tricky to write good tests for modules with a tight 3rd party dependency.

When testing the ENS integration, I need to make sure that the domain is renewed. How do I do that? We can create a mock contract, but as we add more logic to it, we start testing the mock more and the module less. There are also two separate contracts we integrate (the base registrar and the ETH registrar controller), do we need to couple them internally to sync the state?

In general, I don’t think there’s a simple answer to it. I ended up with 2 mock contracts connected to each other. The mocks implement the bare bones version of the actual ENS contracts, as well as provide a helper functions to keep the state synced.