Building the module
To get started building the validator, we will first of all create a new contract that inherits fromERC7579ValidatorBase and has all the needed imports:
ownerId key first in order to be compliant with the [ERC-4337 restrictions(/erc4337-validation). We also use the SignatureCheckerLib to check the signatures of the owners.
Then, we add the config logic of the module:
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:
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:
Testing the module
First, we will create a new.t.sol file with the following content:
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:
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:
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: