ModuleKit
Multi-Owner Validator

Building a Muti-Owner Validator

In this tutorial, we will walk you through building a simple validator module, in this case, a multi-owner validator. This validator module allows multiple owners to sign transactions using an EOA. Before getting started, make sure you have ModuleKit installed and have set up a repo to work from.

  1. Building the module

To get started building the validator, we will first of all create a new contract that inherits from ERC7579ValidatorBase and has all the needed imports:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
 
import { ERC7579ValidatorBase } from "modulekit/Modules.sol";
import { PackedUserOperation } from "modulekit/external/ERC4337.sol";
 
import { SignatureCheckerLib } from "solady/src/utils/SignatureCheckerLib.sol";
import { ECDSA } from "solady/src/utils/ECDSA.sol";
 
contract MultiOwnerValidator is ERC7579ValidatorBase {}

Next, we will implement the required storage variables and library usage:

...
 
contract MultiOwnerValidator is ERC7579ValidatorBase {
    using SignatureCheckerLib for address;
 
    mapping(uint256 ownerId => mapping(address account => address)) public owners;
    mapping(address account => uint256) public ownerCount;
}

We set up a mapping to store the owners and their accounts, and another mapping to store the number of owners for each account. Note that we use a mapping for gas efficiency. Although it seems a bit counterintuitive, we need to use the ownerId key first in order to be compliant with the ERC-4337 restrictions. We also use the SignatureCheckerLib to check the signatures of the owners.

Then, we add the config logic of the module:

...
 
contract MultiOwnerValidator is ERC7579ValidatorBase {
    ...
 
    /*//////////////////////////////////////////////////////////////////////////
                                     CONFIG
    //////////////////////////////////////////////////////////////////////////*/
 
    /* Initialize the module with the given data
     * @param data The data to initialize the module with
     */
    function onInstall(bytes calldata data) external override {
        owners[0][msg.sender] = address(uint160(bytes20(data)));
        ownerCount[msg.sender] = 1;
    }
 
    /* De-initialize the module with the given data
     * @param data The data to de-initialize the module with
     */
    function onUninstall(bytes calldata data) external override {
        uint256 _ownerCount = ownerCount[msg.sender];
        for (uint256 i = 0; i < _ownerCount; i++) {
            delete owners[i][msg.sender];
        }
        delete ownerCount[msg.sender];
    }
 
    /*
     * Check if the module is initialized
     * @param smartAccount The smart account to check
     * @return true if the module is initialized, false otherwise
     */
    function isInitialized(address smartAccount) external view returns (bool) {
        return ownerCount[smartAccount] > 0;
    }
}

In the onInstall function, we initialize the module with the given data. In this case, we set the first owner that is passed (using abi.encodePacked) in the data. In the onUninstall function, we de-initialize the module by removing all the owners of the account. In the isInitialized function, we check if the module is initialized by checking if the owner count is greater than 0, ie whether an account has owners.

Next, we add the module logic:

...
 
contract MultiOwnerValidator is ERC7579ValidatorBase {
    ...
 
    /*//////////////////////////////////////////////////////////////////////////
                                     MODULE LOGIC
    //////////////////////////////////////////////////////////////////////////*/
 
    /**
     * Validates UserOperation
     * @param userOp PackedUserOperation to be validated.
     * @param userOpHash Hash of the UserOperation to be validated.
     * @return sigValidationResult the result of the signature validation, which can be:
     *  - 0 if the signature is valid
     *  - 1 if the signature is invalid
     *  - <20-byte> aggregatorOrSigFail, <6-byte> validUntil and <6-byte> validAfter (see ERC-4337
     * for more details)
     */
    function validateUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    )
        external
        view
        override
        returns (ValidationData)
    {
        (uint256 _ownerId, bytes memory _singature) = abi.decode(userOp.signature, (uint256, bytes));
        bool validSig = owners[_ownerId][msg.sender].isValidSignatureNow(
            ECDSA.toEthSignedMessageHash(userOpHash), _singature
        );
        return _packValidationData(!validSig, type(uint48).max, 0);
    }
 
    /**
     * Validates an ERC-1271 signature
     * @param sender The sender of the ERC-1271 call to the account
     * @param hash The hash of the message
     * @param signature The signature of the message
     * @return sigValidationResult the result of the signature validation, which can be:
     *  - EIP1271_SUCCESS if the signature is valid
     *  - EIP1271_FAILED if the signature is invalid
     */
    function isValidSignatureWithSender(
        address sender,
        bytes32 hash,
        bytes calldata signature
    )
        external
        view
        virtual
        override
        returns (bytes4 sigValidationResult)
    {
        (uint256 _ownerId, bytes memory _singature) = abi.decode(signature, (uint256, bytes));
        address owner = owners[_ownerId][msg.sender];
        address recover = ECDSA.recover(hash, _singature);
        bool valid = SignatureCheckerLib.isValidSignatureNow(owner, hash, _singature);
        return SignatureCheckerLib.isValidSignatureNow(owner, hash, _singature)
            ? EIP1271_SUCCESS
            : EIP1271_FAILED;
    }
 
    /*
     * Add an owner to the smart account
     * @param ownerId The owner ID
     * @param owner The owner to add
     */
    function addOwner(uint256 ownerId, address owner) external {
        require(owners[ownerId][msg.sender] == address(0), "Owner already exists");
        owners[ownerId][msg.sender] = owner;
        ownerCount[msg.sender]++;
    }
 
    /*
     * Remove an owner from the smart account
    * @dev Does not decrease ownerCount as this could result in owner not being removed during
        uninstall
     * @param ownerId The owner ID
     */
    function removeOwner(uint256 ownerId) external {
        delete owners[ownerId][msg.sender];
    }
}

In the validateUserOp function, we validate the user operation by checking if the signature is valid. In the isValidSignatureWithSender function, we validate the ERC-1271 signature by checking if the signature is valid. For both of these, we encode the ownerId in the signature to use the wanted owner.

We also add the addOwner and removeOwner functions to add and remove owners from the smart account.

Finally, we add the metadata logic:

...
 
contract MultiOwnerValidator is ERC7579ValidatorBase {
    ...
 
    /*//////////////////////////////////////////////////////////////////////////
                                     METADATA
    //////////////////////////////////////////////////////////////////////////*/
 
    /**
     * The name of the module
     * @return name The name of the module
     */
    function name() external pure returns (string memory) {
        return "MultiOwnerValidator";
    }
 
    /**
     * The version of the module
     * @return version The version of the module
     */
    function version() external pure returns (string memory) {
        return "0.0.1";
    }
 
    /*
        * Check if the module is of a certain type
        * @param typeID The type ID to check
        * @return true if the module is of the given type, false otherwise
        */
    function isModuleType(uint256 typeID) external pure override returns (bool) {
        return typeID == TYPE_VALIDATOR;
    }
}

This is pretty standard. We define the name and version of the module and check if the module is of a certain type.

We have now successfully built a multi-owner validator module. The next step is to test the module.

  1. Testing the module

First, we will create a new .t.sol file with the following content:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
 
import { Test } from "forge-std/Test.sol";
import {
    RhinestoneModuleKit,
    ModuleKitHelpers,
    ModuleKitUserOp,
    AccountInstance,
    UserOpData
} from "modulekit/ModuleKit.sol";
import { MODULE_TYPE_VALIDATOR } from "modulekit/external/ERC7579.sol";
import { MultiOwnerValidator } from "src/MultiOwnerValidator.sol";
import { ECDSA } from "solady/src/utils/ECDSA.sol";
 
contract MultiOwnerValidatorTest is RhinestoneModuleKit, Test {}

In this file, we import various contracts from the ModuleKit and Test from forge-std. We also import the MultiOwnerValidator contract we just created, and the ECDSA library from solady as a helper. Then, we create a new contract that inherits from RhinestoneModuleKit and Test.

Next, we will set up the test:

...
 
contract MultiOwnerValidatorTest is RhinestoneModuleKit, Test {
    using ModuleKitHelpers for *;
    using ModuleKitUserOp for *;
    using ECDSA for bytes32;
 
    // account and modules
   AccountInstance internal instance;
    MultiOwnerValidator internal validator;
 
    Account owner1;
    Account owner2;
 
    function setUp() public {
        init();
 
        // Create the validator
        validator = new MultiOwnerValidator();
        vm.label(address(validator), "MultiOwnerValidator");
 
        // Create the owners
        owner1 = makeAccount("owner1");
        owner2 = makeAccount("owner2");
 
        // Create the account and install the validator
        instance = makeAccountInstance("MultiOwnerValidator");
        vm.deal(address(instance.account), 10 ether);
        instance.installModule({
            moduleTypeId: MODULE_TYPE_VALIDATOR,
            module: address(validator),
            data: abi.encodePacked(owner1.addr)
        });
    }
}

We use the ModuleKitHelpers and ModuleKitUserOp to help with the integration testing and the ECDSA library to help with the signatures. We then set up the test by creating the account instance, validator and owners. During setUp, we create the account instance, owners and install the validator.

Next, we will test the validator:

...
 
contract MultiOwnerValidatorTest is RhinestoneModuleKit, Test {
    ...
 
    function signHash(uint256 privKey, bytes32 digest) internal returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, ECDSA.toEthSignedMessageHash(digest));
        return abi.encodePacked(r, s, v);
    }
 
    function execAndAssert(uint256 ownerId, uint256 key) internal {
        // Create a target address and send some ether to it
        address target = makeAddr("target");
        uint256 value = 1 ether;
 
        // Get the current balance of the target
        uint256 prevBalance = target.balance;
 
        // Get the UserOp data (UserOperation and UserOperationHash)
        UserOpData memory userOpData = instance.getExecOps({
            target: target,
            value: value,
            callData: "",
            txValidator: address(validator)
        });
 
        // Set the signature
        bytes memory signature = signHash(key, userOpData.userOpHash);
        userOpData.userOp.signature = abi.encode(ownerId, signature);
 
        // Execute the UserOp
        userOpData.execUserOps();
 
        // Check if the balance of the target has increased
        assertEq(target.balance, prevBalance + value);
    }
 
    function testOwner1() public {
        execAndAssert(0, owner1.key);
    }
 
    function testOwner2() public {
        instance.exec({
            target: address(validator),
            callData: abi.encodeWithSelector(
                MultiOwnerValidator.addOwner.selector, uint256(1), owner2.addr
                )
        });
 
        execAndAssert(1, owner2.key);
    }
}

First, we add two utility functions, signHash and execAndAssert. The first of these is a helper to make it easier to create the signature of the UserOperation. The second actually holds the majority of the testing logic, but we have extracted it as a non-test function to make the tests more readable by reusing the code. The execAndAssert creates calldata and a UserOpData object, sets the signature, and then executes the UserOp. Finally, it asserts that the UserOp was executed successfully. We then have two tests, testOwner1 and testOwner2, which test the validator by executing a UserOp with the first and second owner, respectively.

When you run the tests, you should see that they pass:

Running 2 tests for test/MultiOwnerValidator.t.sol:MultiOwnerValidatorTest
[PASS] testOwner1() (gas: 251446)
[PASS] testOwner2() (gas: 357980)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 4.21ms
 
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)