Destiner's Notes

ERC-7579 Validators with ModuleKit

17 October 2024

Code is available on GitHub

Introduction

In this piece, we will create an ERC-7579 compatible validator using Rhinestone ModuleKit.

ERC-7579 is a standard for Smart Account modules. It strives to be vendor-agnostic by decoupling account implementation details from the module logic. The standard introduces multiple module types, including validators, executors, hooks, etc. Learn more at erc7579.com.

Validators define User Operation validation rules. A validator takes the validation data to decide whether the operation is valid based on the module configuration. This happens during the validateUserOp phase of the ERC-4337 flow. Learn more in the ERC-7579 validator spec.

Validators were designed to serve a single function: to tell whether the User Operation is OK or not.

Note: validators (module id = 1) and stateless validators (module id = 7) are two different things. See the “Stateless Validators” section below

ModuleKit is a smart contract framework designed to simplify module authoring. It provides some helpful abstractions, as well as testing utilities to make it easier to write, debug, and test the modules. While it’s not required to use ModuleKit to make modules, it can reduce the cognitive load and help make the code more secure. Learn more in the Rhinestone docs.

Motivation

In most cases, you don’t need to make a new validator. There are plenty of well-written and audited validators that cover ECDSA and WebAuthn signature validations, as well as multisigs. I’d expect most innovations with validators to be on the UI level, combining multiple modules to create a novel experience.

However, there are a few interesting ideas. Specifically, ZK Email-powered modules can open up a few interesting possibilities.

Here, we will make a token-based validator. Enabling this validator for a smart account will essentially turn it into a token-gated liquid multisig. During validation, the module will recover the signers using ECDSA signatures and check their respective balances. The module will support validations based on ERC-20, ERC-721, and ERC-1155 tokens, allowing to set the custom balance and signer thresholds.

Stateless Validators

ERC-7780 is an extension of the original standard that defines a new module type: stateless validators. Stateful validators (or just validators) validate operations based on both state (config data) and the validation data. Stateless validators are a lower-level mechanism that isn’t responsible for storing config data and instead focused on performing the validation against supplied data.

As per the standard,

It is RECOMMENDED that all Validators (module type id 1) also implement the Stateless Validator interface for additional composability.

To keep our code simple, we will focus on implementing the stateful flavour. Stateless validators are great to verify signatures based on a selected cryptographic scheme, but might be less useful for esoteric use cases like ours.

It might be a good idea though to use an existing stateless ECDSA validator to verify the signatures in our validation logic.

Validators vs Signers

Speaking of EIP-7780, it also defines Signers (module id = 6). Signers were created specifically to validate the signatures. Similar to validators, they issue validations based the on config data. Unlike validators, signers are more composable and can be used in tandem with other modules to validate an operation, although the line is blurry.

I’d personally expect most validation modules to implement all three validation types. For simplicity, we will focus on implementing the stateful validators.

Getting started

We will be using the Module Template.

Once you cloned the repo and installed the dependencies, you can start writing your validator contract. I’d suggest copying src/ValidatorTemplate.sol and removing all the implementation code.

The two most important functions to implement would be onInstall and validateUserOp. The first one is called when the module is installed to an account and the second one is called each time a new UserOp is validated.

As part of the flow, onInstall will pass arbitrary data bytes (we will call this config data). It’s up to you, the module author, how to interpret that data. A common pattern is to define a structured way to configure the module. End user applications then will build UIs around that structure, and format the data properly. Your only goal here is to validate and store that data.

Note: strive to validate the config data as much as possible. Invalid configuration can wreck the installed module or even the entire account.

The validateUserOp function call will pass the User Operation, as well as its hash. Your job here is to decide whether the operation is valid based on the config data, as well as the operation itself. Again, it’s totally up to you how you decide on that. In most cases, you will rely on the signature and sender (account address) fields of the operation.

We will also need to implement onUninstall to handle the module uninstallation flow, isValidSignatureWithSender to be compatible with ERC-1271, and isInitialized to check whether the module is initialized for a given account. For onUninstall, our implementation will be trivial, as we only need to flush the config data. isInitialized will work by checking that the stored data is available for a given account. As for isValidSignatureWithSender we can reuse most of the validateUserOp logic.

Note: it’s helpful to define an internal function that will handle the validation process, and use that for both validateUserOp and isValidSignatureWithSender whenever possible.

Installation

We will start by defining the config data format. As we deal with tokens, we want to first know the token standard. Of course, we will also need the token address. We will provide a way to define the minimum amount of balance to be an eligible signer, as well as setting how many signers we need to validate the operations. Finally, we will allow limiting access to specific token IDs, which will be handy for ERC-721 and ERC-1155 tokens.

enum TokenType {
    ERC20,
    ERC721,
    ERC1155
}

struct TGAConfig {
    TokenType tokenType;
    address tokenAddress;
    uint256 minAmount;
    uint256[] validTokenIds;
    uint256 signerThreshold;
}

Next, we will need to implement isInitialized. For this, we will simply check that the token address is not null:

function isInitialized(address smartAccount) public view returns (bool) {
    // get storage reference to account config
    TGAConfig storage $config = accountConfig[smartAccount];
    // check if the token address is not 0
    return $config.tokenAddress != address(0);
}

Now, to onInstall. We will start by making sure that the module wasn’t installed before:

if (isInitialized(account)) revert AlreadyInitialized(account);

Then, we will parse and validate the config data:

TGAConfig memory config = abi.decode(data, (TGAConfig));
if (config.tokenAddress == address(0)) revert InvalidTokenAddress();
if (config.minAmount == 0) revert InvalidMinAmount();
if (config.signerThreshold == 0) revert InvalidSignerThreshold();
if (config.tokenType == TokenType.ERC20 && config.validTokenIds.length > 0) {
    revert InvalidTokenIds();
}

Finally, let’s store the data:

accountConfig[account] = config;

Validation

Once the module is installed, it can be used for validation. It’s up to the account and the UI to decide when and which validator to use, our only job here is to actually validate the operation that was passed to the module.

We will start by checking that the module is initialized:

function validateUserOp(
    PackedUserOperation calldata userOp,
    bytes32 userOpHash
)
    external
    view
    override
    returns (ValidationData)
{
    address account = userOp.sender;

    if (isInitialized(account) == false) {
        return VALIDATION_FAILED;
    }

    // …
}

Now, let’s recover the owner addresses from the signatures:

import { CheckSignatures } from "checknsignatures/CheckNSignatures.sol";
import { ECDSA } from "solady/utils/ECDSA.sol";
import { LibSort } from "solady/utils/LibSort.sol";

// …

{
    // …

    // get the account config
    TGAConfig storage config = accountConfig[account];
    uint256 _threshold = config.signerThreshold;

    // recover the signers from the signatures
    address[] memory signers =
        CheckSignatures.recoverNSignatures(ECDSA.toEthSignedMessageHash(hash), data, _threshold);

    // sort and uniquify the signers to make sure a signer is not reused
    signers.sort();
    signers.uniquifySorted();

    // …
}

Finally, we will validate the signer balances and check the signer threshold. Taking ERC-20 balances as an example:

{
    // …

    uint256 validSigners;
    uint256 signersLength = signers.length;
    for (uint256 i; i < signersLength; i++) {
        address signer = signers[i];
        if (config.tokenType == TokenType.ERC20) {
            uint256 balance = IERC20(config.tokenAddress).balanceOf(signer);
            if (balance >= config.minAmount) {
                validSigners++;
            }
        }
        // other token types…
    }

    // check if the threshold is met and return the result
    bool aboveThreshold = validSigners >= _threshold;
    if (aboveThreshold) {
        return VALIDATION_SUCCESS;
    }
    return VALIDATION_FAILED;
}

Storage restrictions

The validation code above will pass the tests and might even work in practice. Unfortunately, it is illegal. You see, the ERC-4337 flow defines a set of strict access rules to prevent DoS attacks. The full list of those rules is defined in ERC-7562, here’s the relevant bit:

Associated storage: a storage slot of any smart contract is considered to be “associated” with address A if:

  1. The slot value is A
  2. The slot value was calculated as keccak(A||x)+n, where x is a bytes32 value, and n is a value in the range 0..128

During validation, we can only access the storage associated with the account.

In Solidity, the validator is able to access a state of any contract as long as it’s a mapping with an index being an account. For example, a validator for a smart account A can access the following variables:

On the other hand, accessing those variables would be illegal:

As balanceOf(signer) doesn’t access the mapping that ends with account address, it will access an illegal storage slot. It’s up to the bundlers to enforce those rules, and some bundlers might accept an operation with illegal storage access. But to make our validator compatible with the spec, we will need to make some changes.

Specifically, we will introduce a staking contract that will handle the token accounting for each account. The staking contract will allow staking tokens for each account, and track the balances. The validator then will access the staking balances for a specific account during the validation. Of course, this will make the UX worse, but AFAIK this is the only way to make our idea compatible with the storage access rules.

Continuing with ERC-20 as an example, our token staker contract will look list this:

contract TokenStaker {
    mapping(address owner => mapping(IERC20 token => mapping(address account => uint256 balance)))
        internal erc20Stakes;

    function stakeErc20(address account, IERC20 tokenAddress, uint256 amount) external {
        if (account == address(0)) {
            revert InvalidAccount();
        }

        tokenAddress.transferFrom(msg.sender, address(this), amount);
        erc20Stakes[msg.sender][tokenAddress][account] += amount;
    }
}

And now the updated validation logic:

{
    for (uint256 i = 0; i < signersLength; i++) {
        address signer = signers[i];
        if (config.tokenType == TokenType.ERC20) {
            uint256 balance =
                TOKEN_STAKER.erc20StakeOf(signer, IERC20(config.tokenAddress), account);
            if (balance >= config.minAmount) {
                validSigners++;
            }
        }
        // other token types…
    }
}

Tip: we can make sure our new implementation does not access the illegal storage slots by running SIMULATE=true forge test. This will run the tests along with the storage checks.

As noted by Fil Makarov, there is another storage access violation. In the module config, the validTokenIds param is an array. Accessing any element of that array will result in storage read outside the allowed range (keccak256(keccak256(A || n) + x || index) to be precise).

To circumvent, we can use the FlatBytes library to store dynamic data as a single blob of bytes:

import { FlatBytesLib } from "flatbytes/BytesLib.sol";

struct TGAConfig {
    TokenType tokenType;
    address tokenAddress;
    uint256 minAmount; // minimum amount of tokens to be valid
    uint256 signerThreshold; // minimum number of signer to be valid
    FlatBytesLib.Bytes validTokenIds; // valid token IDs; empty array means all IDs are valid
}

Note that we moved the validTokenIds to the end of the struct to prevent it from overriding other params.

We will create a separate struct for data passed during the installation. This is so we can access the validTokenIds param for input validation.

struct InstallData {
    TokenType tokenType;
    address tokenAddress;
    uint256 minAmount; // minimum amount of tokens to be valid
    uint256 signerThreshold; // minimum number of signer to be valid
    uint256[] validTokenIds; // valid token IDs; empty array means all IDs are valid
}

Let’s tweak our onInstall hook to handle the struct changes:

using FlatBytesLib for FlatBytesLib.Bytes;

function onInstall(bytes calldata data) external override {
    // cache the account
    address account = msg.sender;
    // check if the module is already initialized and revert if it is
    if (isInitialized(account)) revert AlreadyInitialized(account);

    // input validation
    InstallData memory data = abi.decode(data, (InstallData));
    if (data.tokenAddress == address(0)) revert InvalidTokenAddress();
    if (data.minAmount == 0) revert InvalidMinAmount();
    if (data.signerThreshold == 0) revert InvalidSignerThreshold();
    if (data.tokenType == TokenType.ERC20 && data.validTokenIds.length > 0) {
        revert InvalidTokenIds();
    }

    // store the config
    TGAConfig storage $config = accountConfig[account];
    $config.tokenType = data.tokenType;
    $config.tokenAddress = data.tokenAddress;
    $config.minAmount = data.minAmount;
    $config.signerThreshold = data.signerThreshold;
    $config.validTokenIds.store(abi.encode(data.validTokenIds));
}

During the balance checks for ERC-721 and ERC-1155 tokens, we will need to convert the flat bytes back to the list of uint256:

uint256[] memory tokenIds = abi.decode(config.validTokenIds.load(), (uint256[]));

Configuration updates

If you have some configuration in your validator, it usually makes sense to allow you to tweak it after the installation. For example, a multisig validator may allow changing the owner threshold, as well as adding and removing owners.

For our validator, we will allow changing the minimum balance and the signer threshold. We will NOT allow changing the token address or the token type, as that may lead to fatal mistakes. We will make sure to validate the input.

function setMinAmount(uint256 minAmount) external {
    if (minAmount == 0) revert InvalidMinAmount();

    // cache the account
    address account = msg.sender;
    // check if the module is initialized and revert if it is not
    if (!isInitialized(account)) revert NotInitialized(account);

    TGAConfig storage $config = accountConfig[account];
    $config.minAmount = minAmount;
}

function setSignerThreshold(uint256 signerThreshold) external {
    if (signerThreshold == 0) revert InvalidSignerThreshold();

    // cache the account
    address account = msg.sender;
    // check if the module is initialized and revert if it is not
    if (!isInitialized(account)) revert NotInitialized(account);

    TGAConfig storage $config = accountConfig[account];
    $config.signerThreshold = signerThreshold;
}

Last touches

To make our implementation complete, let’s implement onUnistall and isValidSignatureWithSender.

First, the module uninstallation hook:

function onUninstall(bytes calldata) external override {
    // clean up the account config
    delete accountConfig[msg.sender];
}

To implement the signature validation, we will first extract the validation logic into a separate internal function:

function _validateSignatureWithConfig(
    address account,
    bytes32 hash,
    bytes calldata data
)
    internal
    view
    returns (bool)
{
    if (isInitialized(account) == false) {
        return false;
    }

    // …

    // check if the threshold is met and return the result
    return validSigners >= _threshold;
}

We will then rewrite the validateUserOp to use that:

function validateUserOp(
    PackedUserOperation calldata userOp,
    bytes32 userOpHash
)
    external
    view
    override
    returns (ValidationData)
{
    // validate the signature with the config
    bool isValid = _validateSignatureWithConfig(userOp.sender, userOpHash, userOp.signature);

    // return the result
    if (isValid) {
        return VALIDATION_SUCCESS;
    }
    return VALIDATION_FAILED;
}

Now we can reuse that in the isValidSignatureWithSender function:

function isValidSignatureWithSender(
    address sender,
    bytes32 hash,
    bytes calldata signature
)
    external
    view
    virtual
    override
    returns (bytes4 sigValidationResult)
{
    // validate the signature with the config
    bool isValid = _validateSignatureWithConfig(sender, hash, signature);

    // return the result
    if (isValid) {
        return EIP1271_SUCCESS;
    }
    return EIP1271_FAILED;
}

Testing

To make sure our implementation is valid, it’s crucial to cover our codebase with tests extensively.

Here, instead of sharing the actual test cases, I’d like to go through the general concepts and ideas of testing validation modules.

When it comes to writing module tests, arguably the best source of inspiration is the core-modules repo by Rhinestone. I highly suggest studying it. For our case, the OwnableValidator and MultiFactor are the most relevant contracts.

Some high-level takeaways:

Some general-purpose testing tips:

Bonus: signature replay protection

When it comes to EIP-1271 signatures, one needs to be careful using them onchain. Since signatures are ultimately based on the owner keys, multiple accounts with overlapping signer sets can potentially be replayable. For example, if accounts A and B are owned by the same EOA, signing a hash for account A will also make it valid for account B. It means that any signature-based transaction for account A can be replayed for account B, without any input from its owner.

This can be mediated by wrapping the signed hash into a replay-safe EIP-712 struct. However, in that case, the original signed data will be opaque for the signer, as the wallet will show the data hash instead of the actual content.

ERC-7739 was introduced as a user-friendly alternative. Instead of wrapping the message digest, it wraps the entire signed message contents into an EIP-712 struct, which then gets signed by the end user. Because the signed data is EIP-712-compatible, any wallet that supports displaying it will be able to show the full message, including the original internal data. The message is signed over the chain ID and the account address, so it is protected against cross-chain and cross-account replay attacks.

To make our signature validation ERC-7739-compatible, we will use the ERC7739 Validator library.

Once you’ve added the library to your project, you can use ERC7739Validator as an extension of your validator:

import { ERC7739Validator } from "@erc7579/erc7739Validator/ERC7739Validator.sol";

contract TokenValidator is ERC7579ValidatorBase, ERC7739Validator {
    // …
}

Now, all we need to do is to provide an implementation of the _erc1271IsValidSignatureNowCalldata method. We will simply reuse the _validateSignatureWithConfig method here:

function _erc1271IsValidSignatureNowCalldata(
    address sender,
    bytes32 hash,
    bytes calldata signature
)
    internal
    view
    virtual
    override
    returns (bool valid)
{
    return _validateSignatureWithConfig(sender, hash, signature);
}

Now let’s update isValidSignatureWithSender to use the nested structure:

function isValidSignatureWithSender(
    address sender,
    bytes32 hash,
    bytes calldata signature
)
    external
    view
    override
    returns (bytes4 result)
{
    bool success = _erc1271IsValidSignatureWithSender(
        sender, hash, _erc1271UnwrapSignature(signature)
    );
    return success ? EIP1271_SUCCESS : EIP1271_FAILED;
}

Thanks to Konrad Kopp and Fil Makarov for their feedback.