Writing a MultiSig contract using Solidity

Writing a MultiSig contract using Solidity

If you're new to the world of smart contracts, you may not be familiar with the concept of multi-sig. Multisig, short for multi-signature, is a security feature that requires multiple parties to approve a transaction before it can be executed on the blockchain. In this tutorial, I'll walk you through a simple multi-sig contract written in Solidity. This is the first part of a series where we will be creating a multi-sig dApp from scratch.

If you prefer video tutorials instead, I have made a video on this same topic on my YouTube channel. Also, make sure you leave any comments on any dApp idea you want me to build as a tutorial!

What does this contract do?

This Solidity contract is a multi-sig wallet that allows a group of owners to manage a shared account. Transactions submitted to the contract require a certain number of signatures from the owners before they can be executed. This provides an added layer of security since no single owner can perform transactions without the approval of the required number of owners.

Setting up the development environment

Creating a hardhat project

We will use hardhat in this tutorial to compile and manage our contract. Navigate to a safe directory, and run the following command to initialize the hardhat project initialization wizard:

npx hardhat

This should load up the initializer. Choose your configuration; in this case, I'll be choosing JavaScript (although we won't be writing scripts in this tutorial, although if you wish to do so, this choice might be important for you). Finally, choose to install the dependencies for your hardhat project.

Creating the contract file

Hardhat creates a file named Lock.sol within the contracts folder with some example code for you. You can delete the file and create a new file named Multisig.sol. Then you can initialize a simple contract.

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

contract Multisig {

}

Writing the contract

Defining the states

Our contract will have states that will store important data about the owners, transaction details and the minimum signatures required for a transaction to get executed.

uint256 private _requiredSignatures;
address[] private _owners;

struct Transaction {
    address to;
    uint256 value;
    bytes data;
    bool executed;
    mapping(address => bool) signatures;
}

Transaction[] private _transactions;

The following states have been used in the above code:

  • _requiredSignatures: This variable determines the number of signatures required for a transaction to be executed. It is set in the constructor and cannot be changed once set.

  • _owners: This is an array of addresses representing the owners of the contract. These addresses are set in the constructor and cannot be changed once set.

  • _transactions: This is an array of Transaction structs that represent the pending transactions. Each transaction contains information about the destination address, value, data, whether it has been executed, and a mapping of which owners have signed the transaction.

The Transaction struct has the following fields:

  • to: The destination address of the transaction.

  • value: The value to be sent in the transaction.

  • data: Any additional data to be sent with the transaction.

  • executed: A boolean flag indicating whether the transaction has been executed.

  • signatures: A mapping of which owners have signed the transaction. This allows us to easily check if the required number of signatures have been obtained for a given transaction.

Defining the events

Our contract will have events that we can emit and will be useful when we create an app to know the status of our transaction:

event TransactionCreated(uint256 transactionId, address to, uint256 value, bytes data);
event TransactionSigned(uint256 transactionId, address signer);
event TransactionExecuted(uint256 transactionId, address executer);
  • TransactionCreated event is emitted when one of the owners of the contract creates a transaction for them and others to sign.

  • TransactionSigned event is emitted when someone signs an existing transaction on the contract.

  • TransactionExecuted event is emitted when a transaction is successfully executed.

Writing the constructor

This code will be run when the contract is first deployed. We will use this to set the owners of the contracts that can only be set by the constructors. We will also set the required number of signatures for a transaction to get executed.

constructor(address[] memory owners, uint256 requiredSignatures) {
    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 constructor code, we are running some checks like if the owners array is empty or if the number of signatures required is more than the number of owners supplied. In that case, we revert the transaction.

If everything goes well, we set the owners of the contract and also set the required number of signatures.

Writing the isOwner() function

function isOwner(address account) public view returns (bool) {
    for (uint256 i = 0; i < _owners.length; i++) {
        if (_owners[i] == account) {
            return true;
        }
    }
    return false;
}

This function takes an address as an argument and returns a boolean indicating whether the given address is an owner of the contract. It does this by iterating through the _owners array and check if the given address matches any of the owners' addresses.

Writing the countSignatures() function

function countSignatures(Transaction storage transaction) private view returns (uint256) {
    uint256 count = 0;
    for (uint256 i = 0; i < _owners.length; i++) {
        if (transaction.signatures[_owners[i]]) {
            count++;
        }
    }
    return count;
}

This function takes a Transaction struct as an argument and returns the number of signatures obtained for that transaction. It does this by iterating through the _owners array and checking if the corresponding owner has signed the transaction.

Writing the getTransaction() function

function getTransaction(uint256 transactionId) public view returns (address, uint256, bytes memory, bool, uint256) {
    require(transactionId < _transactions.length, "Invalid transaction ID");

    Transaction storage transaction = _transactions[transactionId];
    return (transaction.to, transaction.value, transaction.data, transaction.executed, countSignatures(transaction));
}

This function takes a transaction ID as an argument and returns information about that transaction, including the destination address, value, data, whether it has been executed, and the number of signatures obtained for that transaction. It does this by looking up the transaction in the _transactions array and calling countSignatures to obtain the number of signatures.

Writing the getOwners(), getRequiredSignatures() and receive functions

function getOwners() public view returns(address[] memory) {
    return _owners;
}

function getRequiredSignatures() public view returns(uint256) {
    return _requiredSignatures;
}

receive() external payable {}

getOwners() and getRequiredSignatures() functions only return the state values. We have an empty receive() function to enable the contract to receive native tokens.

Writing the submitTransaction() function

function submitTransaction(address to, uint256 value, bytes memory data) public {
    require(isOwner(msg.sender), "Not an owner!");
    require(to != address(0), "Invalid destination address");
    require(value >= 0, "Invalid value");

    uint256 transactionId = _transactions.length;
    _transactions.push();
    Transaction storage transaction = _transactions[transactionId];
    transaction.to = to;
    transaction.value = value;
    transaction.data = data;
    transaction.executed = false;

    emit TransactionCreated(transactionId, to, value, data);
}

This function allows an owner to create a new transaction by providing the destination address, the value of ether to be transferred, and an optional data payload. The function first checks whether the caller is an owner or not, if not it reverts the transaction.

The function also checks if the provided destination address is not null and the provided value is greater than or equal to zero, otherwise it reverts the transaction.

It then creates a new transaction object and adds it to the _transactions array. The new transaction's ID is set to the length of the array before the new transaction is added, which makes it unique. The Transaction object is initialized with the provided destination address, value, data, and executed set to false.

Finally, the function emits an event TransactionCreated with the transaction ID, destination address, value, and data as its parameters.

This function does not require any signatures to be added since it only creates a new transaction. Once a transaction has been created, it can be signed by the required number of owners to execute the transaction.

Writing the signTransaction() function

function signTransaction(uint256 transactionId) public {
    require(transactionId < _transactions.length, "Invalid transaction ID");
    Transaction storage transaction = _transactions[transactionId];
    require(!transaction.executed, "Transaction already executed");
    require(isOwner(msg.sender), "Only owners can sign transactions");
    require(!transaction.signatures[msg.sender], "Transaction already signed by this owner");

    transaction.signatures[msg.sender] = true;
    emit TransactionSigned(transactionId, msg.sender);
    if(countSignatures(transaction) == _requiredSignatures) {
        executeTransaction(transactionId);
    }
}

This function allows an owner of the contract to sign a pending transaction. The owner must specify the transactionId parameter, which represents the index of the transaction in the _transactions array.

The function first checks that the transactionId is valid and that the transaction has not already been executed. It then verifies that the caller is an owner and that they have not already signed the transaction. If all these conditions are met, the function adds the signature of the caller to the signatures mapping of the transaction and emits a TransactionSigned event.

Finally, the function checks if the transaction now has enough signatures to be executed. If the required number of signatures is met, the function calls executeTransaction to execute the transaction.

Writing the executeTransaction() function

function executeTransaction(uint256 transactionId) private {
    require(transactionId < _transactions.length, "Invalid transaction ID");
    Transaction storage transaction = _transactions[transactionId];
    require(!transaction.executed, "Transaction already executed");
    require(countSignatures(transaction) >= _requiredSignatures, "Insufficient valid signatures");

    transaction.executed = true;
    (bool success,) = transaction.to.call{value: transaction.value}(transaction.data);
    require(success, "Transaction execution failed");
    emit TransactionExecuted(transactionId, msg.sender);
}

The function first checks whether the provided transaction ID is valid (i.e., it is within the bounds of the _transactions array). It then checks whether the transaction with the provided ID has already been executed. If it has, the function reverts with an error message.

The function then checks whether the number of valid signatures on the transaction is greater than or equal to the required number of signatures. If it is not, the function reverts with an error message.

If all of these checks pass, the function sets the executed flag of the transaction to true, indicating that the transaction has been executed. It then attempts to execute the transaction by calling the call function on the destination address with the specified value and data. If the transaction execution is successful, the function emits a TransactionExecuted event with the ID of the executed transaction and the address of the function caller.

Complete contract

Here is the full contract in case you missed some part or don't want to code along:

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

contract Multisig {
    uint256 private _requiredSignatures;
    address[] private _owners;

    struct Transaction {
        address to;
        uint256 value;
        bytes data;
        bool executed;
        mapping(address => bool) signatures;
    }

    Transaction[] private _transactions;

    event TransactionCreated(uint256 transactionId, address to, uint256 value, bytes data);
    event TransactionSigned(uint256 transactionId, address signer);
    event TransactionExecuted(uint256 transactionId, address executer);

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

        _owners = owners;
        _requiredSignatures = requiredSignatures;
    }

    function submitTransaction(address to, uint256 value, bytes memory data) public {
        require(isOwner(msg.sender), "Not an owner!");
        require(to != address(0), "Invalid destination address");
        require(value >= 0, "Invalid value");

        uint256 transactionId = _transactions.length;
        _transactions.push();
        Transaction storage transaction = _transactions[transactionId];
        transaction.to = to;
        transaction.value = value;
        transaction.data = data;
        transaction.executed = false;

        emit TransactionCreated(transactionId, to, value, data);
    }

    function signTransaction(uint256 transactionId) public {
        require(transactionId < _transactions.length, "Invalid transaction ID");
        Transaction storage transaction = _transactions[transactionId];
        require(!transaction.executed, "Transaction already executed");
        require(isOwner(msg.sender), "Only owners can sign transactions");
        require(!transaction.signatures[msg.sender], "Transaction already signed by this owner");

        transaction.signatures[msg.sender] = true;
        emit TransactionSigned(transactionId, msg.sender);
        if(countSignatures(transaction) == _requiredSignatures) {
            executeTransaction(transactionId);
        }
    }

    function executeTransaction(uint256 transactionId) private {
        require(transactionId < _transactions.length, "Invalid transaction ID");
        Transaction storage transaction = _transactions[transactionId];
        require(!transaction.executed, "Transaction already executed");
        require(countSignatures(transaction) >= _requiredSignatures, "Insufficient valid signatures");

        transaction.executed = true;
        (bool success,) = transaction.to.call{value: transaction.value}(transaction.data);
        require(success, "Transaction execution failed");
        emit TransactionExecuted(transactionId, msg.sender);
    }

    // HELPERS

    function isOwner(address account) public view returns (bool) {
        for (uint256 i = 0; i < _owners.length; i++) {
            if (_owners[i] == account) {
                return true;
            }
        }
        return false;
    }

    function countSignatures(Transaction storage transaction) private view returns (uint256) {
        uint256 count = 0;
        for (uint256 i = 0; i < _owners.length; i++) {
            if (transaction.signatures[_owners[i]]) {
                count++;
            }
        }
        return count;
    }

    function getTransaction(uint256 transactionId) public view returns (address, uint256, bytes memory, bool, uint256) {
        require(transactionId < _transactions.length, "Invalid transaction ID");

        Transaction storage transaction = _transactions[transactionId];
        return (transaction.to, transaction.value, transaction.data, transaction.executed, countSignatures(transaction));
    }

    function getOwners() public view returns(address[] memory) {
        return _owners;
    }

    function getRequiredSignatures() public view returns(uint256) {
        return _requiredSignatures;
    }

    receive() external payable {}
}

Deployment and testing

Watch my video on my YouTube channel on this exact topic to learn more about deploying and testing the contract. We go in-depth while testing this contract.

Conclusion

In this article, you learned how you can create your own multi-sig contract using Solidity. Two more articles are remaining in this series where we will be creating a contract factory around this contract and finally a dApp to let anyone deploy their own multi-sig wallet.