Writing a factory for MultiSig contract using Solidity

Writing a factory for MultiSig contract using Solidity

In the previous article in this series, we saw how you could create your multi-sig wallet contract using Solidity. In this article, we will continue the journey and create a contract factory for that multi-sig contract we developed earlier.

This will allow anyone to deploy their multi-sig contract through your contract (and the app we will make later in the series) and enable cheap deployments. This contract factory will clone the already-deployed multi-sig contract and configure it according to the user deploying it. We will also have a built-in registry to track who deployed the contract and the list of contracts deployed by a specific address.

If you prefer video tutorials, I have a video on this same topic on my YouTube channel. Make sure you go watch the video if you prefer videos over articles!

Changes to the multi-sig contract

To make our multi-sig contract proxy-friendly and deployable by our contract factory, we need to make a minor change to our multi-sig contract. Instead of using a constructor, we need to use a regular function, which we will name initialize. This is because when a contract is cloned, the constructor is not called, so there's no way for us to set the owners just after the contract is deployed. Luckily, OpenZeppelin has a Initializable contract that helps us make our custom function act like a constructor, i.e., make it so that it can only be run once, just like a constructor.

So, when the contract is deployed, the initialize function is called to do the job constructor was supposed to do and after that, initialize function cannot be used on the same contract again!

Add the following import to your Multisig.sol contract:

import "@openzeppelin/contracts/proxy/utils/Initializable.sol";

Now, you need to inherit this contract:

contract Multisig is Initializable {
    ...
}

Now, let's edit the constructor to be a function named initialize:

function initialize(address[] memory owners, uint256 requiredSignatures) public initializer {
    require(owners.length > 0, "At least one owner required");
    require(requiredSignatures > 0 && requiredSignatures <= owners.length, "Invalid number of required signatures");

    _owners = owners;
    _requiredSignatures = requiredSignatures;
}

In the above code, we are also using the initializer modifier that comes with Initializable contract that makes this function to be able to run only once.

Creating the factory contract

Now that we made our multi-sig contract factory deployment friendly, we can proceed to write our factory contract. Create a new file under contracts folder named MultisigFactory.sol.

Importing OpenZeppelin libraries

We need to use some OpenZeppelin libraries here as well:

import "@openzeppelin/contracts/proxy/Clones.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
  • Clones allow us to clone any contract just by providing the contract address. If you look at the source code, it's all assembly. Under the hood, it's creating a contract that points to an existing implementation.

  • EnumerableSet will allow us to have an AddressSet and have easy functions to deal with addresses. We will use this to save all the contracts deployed by a particular wallet (basically acting like a registry).

States and events

Our contract factory will have the following states and events:

using EnumerableSet for EnumerableSet.AddressSet;

address public owner;
address public implementation;
mapping(address => EnumerableSet.AddressSet) private deployments;

event ImplementationUpdated(address _caller, address _implementation);
event ContractDeployed(address _deployer, address _deployedContract, address _implementation);
  • owner is the owner of the contract factory that is allowed to update the implementation address.

  • implementation is the contract that will be cloned when someone calls the deployContract() function, which we will take a look at later.

  • deployments keep track of the multi-sig contracts deployed by wallets. You can think AddressSet to be an array of addresses but with easy functions available.

  • ImplementationUpdated event is fired when the contract owner updates the implementation, changing the contract deployed moving forward.

  • ContractDeployed event is fired whenever someone deploys a contract. It will be super useful when we move to the app part to detect when the contract is successfully deployed.

Writing the constructor

Since a factory is not deploying this contract, and we are deploying it the normal way, we can use the constructor here:

constructor(address _implementation) {
    owner = msg.sender;
    implementation = _implementation;
}

In the above code, we set the contract owner and the implementation.

Writing the setImplementation() function

This function can only be called by the contract owner and will allow the owner to update the implementation that will be cloned moving forward:

function setImplementation(address _implementation) public {
    require(msg.sender == owner, "Not owner!");
    implementation = _implementation;
    emit ImplementationUpdated(msg.sender, _implementation);
}

In the above code, we check if the function caller is the owner; if not, we revert. We set the implementation and emit the ImplementationUpdated event.

Writing the deployContract() function

Following is the code for the deployContract() function:

function deployContract(bytes memory _data) public {
    address deployedContract = Clones.clone(implementation);
    (bool success, ) = deployedContract.call(_data);
    require(success, "Failed to initialize contract!");
    bool added = deployments[msg.sender].add(deployedContract);
    require(added, "Failed to add to registry!");
    emit ContractDeployed(msg.sender, deployedContract, implementation);
}

The deployContract function is used to create a new Multisig contract instance. It takes a parameter of type bytes, the initialization data for the Multisig contract. The function uses the Clones.clone() function to create a new instance of the Multisig contract using the implementation address stored in the state variable. The initialization data is then passed to the new contract instance using the call() function. If the contract is successfully initialized, the address of the new contract is added to a registry maintained by the contract.

Writing the getDeployed() and countDeployed() functions

function getDeployed(address _deployer) public view returns(address[] memory) {
    return deployments[_deployer].values();
}

function countDeployed(address _deployer) public view returns(uint256) {
    return deployments[_deployer].length();
}

The above two functions simply access the registry and return relevant data by using functions from AddressSet.

Complete contract

If you missed something, the following is the complete code for the MultisigFactory.sol contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/proxy/Clones.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

contract MultisigFactory {
    using EnumerableSet for EnumerableSet.AddressSet;

    address public owner;
    address public implementation;
    mapping(address => EnumerableSet.AddressSet) private deployments;

    event ImplementationUpdated(address _caller, address _implementation);
    event ContractDeployed(address _deployer, address _deployedContract, address _implementation);

    constructor(address _implementation) {
        owner = msg.sender;
        implementation = _implementation;
    }

    function setImplementation(address _implementation) public {
        require(msg.sender == owner, "Not owner!");
        implementation = _implementation;
        emit ImplementationUpdated(msg.sender, _implementation);
    }

    function deployContract(bytes memory _data) public {
        address deployedContract = Clones.clone(implementation);
        (bool success, ) = deployedContract.call(_data);
        require(success, "Failed to initialize contract!");
        bool added = deployments[msg.sender].add(deployedContract);
        require(added, "Failed to add to registry!");
        emit ContractDeployed(msg.sender, deployedContract, implementation);
    }

    function getDeployed(address _deployer) public view returns(address[] memory) {
        return deployments[_deployer].values();
    }

    function countDeployed(address _deployer) public view returns(uint256) {
        return deployments[_deployer].length();
    }
}

Writing script to encode data for initialize() function

In the factory contract, we have accepted bytes as a parameter in the deployContract() function. Although, we need to encode the call to the initialize() function to pass the bytes to the function. For testing, I've created a script to encode this data. However, our app will do this automatically, and the user won't need to be in the hassle the encode the data.

Create a new file under scripts folder named generateEncodedData.js and have the following contents:

const hre = require("hardhat");

async function main() {
  const Lock = await hre.ethers.getContractFactory("Multisig");
  const encodedData = await Lock.interface.encodeFunctionData("initialize", [
    ["0x53b8E7c9D1e0E9BdF5e2c3197b070542611995e7"],
    1,
  ]);

  console.log(`Encoded data - ${encodedData}`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Feel free to replace the addresses with the owner addresses you desire.

The script uses the Hardhat framework to retrieve the contract factory for the Multisig contract using the getContractFactory() method. It then calls the encodeFunctionData() method on the contract interface to encode the arguments for the initialize function.

The encodeFunctionData() method takes two parameters. The first parameter is a string representing the name of the function to encode, which is "initialize" in this case. The second parameter is an array of values representing the function's arguments.

In this script, the arguments for the initialize function are an array containing a single address and an integer value of 1. The script encodes these arguments using the contract interface and outputs the resulting encoded data using the console.log() method.

The script output is a string representing the encoded data for the initialize function that can be used to initialize a new instance of the Multisig contract with the specified arguments.

Now you can play around, deploy the contract, pass the encoded data to the deployContract function, and you will find the initialize function already called for you and everything set up.

If you want to see the complete testing for this contract, watch my YouTube video on the same topic where we deploy the factory contract and deploy new multi-sig contracts through it, along with encoding data for initialize function.

Conclusion

In this article, you learnt about writing a contract factory for the multi-sig contract we developed in the previous article. Feel free to refer to the previous article in this series.

If you have any suggestions about content, feel free to leave them in the comments section, I would love to address them!