Published on

Coding a multisig wallet with solidity

Authors
  • avatar
    Name
    Killian Godfrey
    Twitter

Multisig Wallets

A multi-signature wallet is a smart contract wallet which requires multiple private keys to sign and authorize transactions, meaning there must must be consensus between private key holders (which could be one or multiple owners) in order to approve transactions. The exact threshold of required signatures can be defined when creating the wallet contract, e.g. 2 out of a total 3 private keys to sign a transaction. However any combination can be chosen, such as 3/5, 2/2, 3/7, 1/4 etc.

This characteristic of multisig makes it extremely flexible for a wide range of wallet solutions, commonly used by individuals to improve the security of cold storage at the expense of managing multiple private keys, or by groups such as joint accounts between friends and family, corporate funds or even DAO treasuries (where number of owners can go into the double of triple digits).

How to code a multisig in Solidity

Although multisig is a blockchain agnostic idea, we're going to be implementing on Ethereum's network using Solidity, a smart contract programming language.

contract MultiSigWallet {
	// events
	event Deposit(address indexed sender, uint value, uint balance);

	// state variables
	address[] public owners;
	uint public required;
	uint public txnCount;
	mapping (address => bool) public isOwner;

	// receive function allows plain eth deposits
	receive() external payable {
		if (msg.value > 0)
			emit Deposit(msg.sender, msg.value, address(this).balance);
	}

	// constructor
  	constructor(address[] memory _owners, uint _required) validRequirement(_owners.length, _required) {
		require(_required <= _owners.length
			&& _required != 0
			&& _owners.length != 0,
			"Invalid requirement");
		
		for (uint i = 0; i < _owners.length; i++) {
			address owner = _owners[i];
			require(!isOwner[owner], "Owner not unique");
			require(owner != address(0), "Invalid owner");
			isOwner[owner] = true;
		}
		owners = _owners;
		required = _required;
	}
}

This is the bare bones of our MultiSigWallet contract, which will let us setup our owners, required signatures, and be able to accept deposits.

Our state variables:

  • owners an array of all the owner's addresses
  • required how many signatures are required to send a transaction
  • txnCount total # of transactions
  • isOwner a mapping to track if an address is an owner or not Note that all our state variables are public so they can be accessed by anyone

receive function will allow our contract to accept Ether and emits a deposit event for our logs.

To receive Ether, a contract must have a receive, fallback, or a function with the payable modifier. If none of these are present, the contract will reject any Ether sent to it.

The constructor will run once when we first deploy an instance of our contract

  • first we check that there's at least 1 owner, at least 1 required signature, and that there arn't fewer owners than signatures required
  • then loop over each owner address provided, checking for duplicates, that it's not a zero-address and update isOwner mapping if successful
  • finally update our contract's state variables to match

The basics of our contract is setup, but we still can't make transactions. We'll add a number of transaction related events and modifiers which will be used later in our contract functions:

    event Submission(uint indexed txnId, address indexed sender, address indexed to, uint value, bytes data);
    event Confirmation(uint indexed txnId, address indexed sender);
    // event Revocation(address indexed sender, uint indexed transactionId);
    event Execution(uint indexed transactionId);
    event ExecutionFailure(uint indexed transactionId);

    mapping (uint => Transaction) public txns; txn id -> txns
	mapping (uint => mapping (address => bool)) public confirmations; // txn id -> owner -> bool confirmation

	struct Transaction {
		address to;
		uint value;
		bytes data;
		bool executed;
		uint numConfirmations;
	}

	modifier onlyWallet() {
		require(msg.sender == address(this), "Not the wallet");
		_;
	}

	modifier ownerExists(address _owner) {
		require(isOwner[_owner], "Not an owner");
		_;
	}
	
	modifier txnExists(uint _txnId) {
		require(_txnId < txnCount, "Txn does not exist");
		_;
	}

	modifier txnNotExists(uint _txnId) {
		require(_txnId >= txnCount, "Txn already exists");
		_;
	}

	modifier confirmed(uint _txnId, address _owner) {
		require(confirmations[_txnId][_owner], "Txn not confirmed");
		_;
	}
	
	modifier notConfirmed(uint _txnId, address _owner) {
		require(!confirmations[_txnId][_owner], "Txn already confirmed");
		_;
	}

	modifier notExecuted(uint _txnId) {
		require(!txns[_txnId].executed, "Txn already executed");
		_;
	}

	modifier notNull(address _address) {
		require(_address != address(0), "Address is null");
		_;
	}

Some additional state variables:

  • txns a mapping to associate unique ids with transactions (txn id -> Transaction)
  • confirmations a mapping to track if an owner has confirmed a specific transaction or not (txn id -> owner -> bool)
  • Transaction struct is a custom data type which will help us record and handle transactions.

Finally our functions to make transactions:

	// @dev Allows an owner to submit a transaction to the wallet
	function submitTxn(address _to, uint _value, bytes memory _data) public ownerExists(msg.sender) returns (uint _txnId) {
		// adds txn submission to the transaction history
		_txnId = addTxn(_to, _value, _data);
		confirmTxn(_txnId);
	}

	// @dev Internal function to add a new transaction to the transaction mapping
	function addTxn(address _to, uint _value, bytes memory _data) internal notNull(_to) returns (uint _txnId) {
		_txnId = txnCount;
		txns[_txnId] = Transaction({
			to: _to,
			value: _value,
			data: _data,
			executed: false,
			numConfirmations: 0
		});
		txnCount += 1;
		emit Submission(_txnId, msg.sender, _to, _value, _data);
	}

	// @dev Lets an owner confirm a transaction
	function confirmTxn(uint _txnId) public ownerExists(msg.sender) txnExists(_txnId) notConfirmed(_txnId, msg.sender) {
		Transaction storage txn = txns[_txnId];
		txn.numConfirmations += 1;
		confirmations[_txnId][msg.sender] = true;

		emit Confirmation(_txnId, msg.sender);
		executeTxn(_txnId);
	}

	// @dev Revokes a confirmation of a transaction
	function revokeConfirmation(uint _txnId) public ownerExists(msg.sender) txnExists(_txnId) confirmed(_txnId, msg.sender) {
		Transaction storage txn = txns[_txnId];
		txn.numConfirmations -= 1;
		confirmations[_txnId][msg.sender] = false;

		emit Revocation(_txnId, msg.sender);
	}

	// @dev Returns true if a transaction is confirmed the required number of times
	function isConfirmed(uint _txnId) public view returns (bool) {
		uint count = 0;
		for (uint i = 0; i < owners.length; i++) {
			if (confirmations[_txnId][owners[i]])
				count += 1;
			if (count >= required)
				return true;
		}
	}

	// @dev Executes a transaction
	function executeTxn(uint _txnId) public ownerExists(msg.sender) confirmed(_txnId, msg.sender) notExecuted(_txnId) {
		if (isConfirmed(_txnId)) {
			Transaction storage txn = txns[_txnId];
			(bool success, ) = txn.to.call{value: txn.value}(txn.data);
			if (success) {
				txn.executed = true;
				emit Execution(_txnId);
			} else {
				txn.executed = false;
				emit ExecutionFailure(_txnId);
			}
		}
	}
  • submitTxn - only an owner of the wallet may call it. Then attempts to add and confirm the transaction with addTxn and confirmTxn functions
  • addTxn - an internal function, it records the transaction info in our txns mapping, effectively adding it to our transaction history/log
  • confirmTxn - checks our transaction is properly recorded and allows an owner to confirm the transaction. The function updates our state variables to reflect the confirmation, and finally calls executeTxn
  • revokeConfirmation - a very similar function to confirmTxn, however instead will revoke their confirmation, effectively undoing confirmTxn
  • isConfirmed - tells us if the required number of confirmations have been met for a transaction
  • executeTxn - checks isConfirmed is true, before .call attempts to execute the transaction. Finally failure handling updates state variable and emits an event based on on the success

.call is a low-level function call that allows a contract to invoke a function on another contract, or in our case send Ether to an address. This is particularly useful as we don't need to know the function signature of the target contract, so we can "blindly" send Ether to an address

As written in the code above, the transaction pipeline is setup such that when an owner submits a transaction to be approved, it will automatically confirm the transaction on that owner's behalf, and furthermore by confirming any transaction it will also attempt to execute the transaction checking of course for the required number of confirmations each time. This helps to reduce the number of manual interations by the user. You could imagine a situation where the wallet's required number of confirmations is 1, this would cause any transaction submitted by any owner to therefore submit, confirm and execute in succession.

Although our wallet has all the functionality it needs to transact, here are a number of view functions that will help us retreive transaction data and basic info about the wallet contract once it's deployed.

	// Returns the number of confirmations of a txn id
	function getConfirmationCount(uint _txnId) public view returns (uint count) {
		count = 0;
		for (uint i = 0; i < owners.length; i++)
			if (confirmations[_txnId][owners[i]])
				count++;
	}

	// Returns the number of transactions filtered by pending or executed transactions
	// @param pending Inlcude pendings txns
	// @param executed Include executed txns
	function getTransactionCount(bool pending, bool executed) public view returns (uint count) {
		for (uint i = 0; i < txnCount; i++) {
			if (pending && !txns[i].executed
				|| executed && txns[i].executed)
				count += 1;
		}
		return count;
	}
	
	// @dev Returns the owner addresses that have confirmed the given transaction id
	// @param _txnId The transaction id
	// @return _confirmations The addresses that have confirmed the transaction
	function getConfirmations(uint _txnId) public view returns (address[] memory _confirmations) {
		address[] memory confirmationsTemp = new address[](owners.length);
		uint count = 0;
		uint i;
		// loops through owners and counts the number of confirmations for the txn
		for (i = 0; i < owners.length; i++)
			if (confirmations[_txnId][owners[i]]) {
				confirmationsTemp[count] = owners[i];
				count += 1;
			}
		
		_confirmations = new address[](count);
		// adds addresses who have confirmed the txn to the new array
		for (i = 0; i < count; i++)
			_confirmations[i] = confirmationsTemp[i];
	}

	// @dev Returns all the transaction ids in the range
	// @param from Index start position of txn array
	// @param to Index end position of txn array
	// @param pending Include pending transactions
	// @param executed Include executed transactions
	// @return _txnIds The transaction ids
	function getTransactionIds(uint from, uint to, bool pending, bool executed) public view returns (uint[] memory _txnIds) {
		require(from < to, "Invalid range");

		// prevent out of bounds access in for loop
		if (to > txnCount)
			to = txnCount;

		uint[] memory txnIdsTemp = new uint[](to - from);
		uint count = 0;
		uint i;
		// loops through txns and adds filtered txns to txnIdsTemp
		for (i = from; i < to; i++) {
			if (pending && !txns[i].executed
				|| executed && txns[i].executed) {
				txnIdsTemp[count] = i;
				count += 1;
			}
		}
		_txnIds = new uint[](count);
		// adds filtered txns to the new array
		for (i = 0; i < count; i++)
			_txnIds[i] = txnIdsTemp[i];
	}
}

Remember how we made our state variables public? By doing so, we are actually creating getter functions for those variables. These allow us to retreive the value of these variables externally without writing an explicit function to do so.

As an example if we call the function required(), it will return the value of our required variable.

Note that getter functions are for external access to variables. If we would like to reference a variable internally we can just use the named variable, following our example writing required inside our contract would be internal access, whereas this.required() is external access (this references the current contract it is written inside)

Similarly we can use the built-in getter function for public arrays, but they will function slightly differently as we can only access one element of the array at a time this way. owners(0) will return the first element of the array, owners(1) for the second element and so on. To access the entire array we have to write a custom view function:

// Returns all the owners
	function getOwners() public view returns (address[] memory){
		return owners;
	}

Full contract code

Putting this all together

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

contract MultiSigWallet {
    // events
    event Deposit(address indexed sender, uint value, uint balance);
    event Submission(uint indexed txnId, address indexed sender, address indexed to, uint value, bytes data);
    event Confirmation(uint indexed txnId, address indexed sender);
    event Revocation(uint indexed txnId, address indexed sender);
    event Execution(uint indexed transactionId);
    event ExecutionFailure(uint indexed transactionId);

	// storage
	mapping (uint => Transaction) public txns; // transaction id -> transaction
	mapping (uint => mapping (address => bool)) public confirmations; // txn id -> owner -> bool confirmation
	mapping (address => bool) public isOwner; // address -> bool isOwner
	address[] public owners;
	uint public required;
	uint public txnCount;

	struct Transaction {
		address to;
		uint value;
		bytes data;
		bool executed;
		uint numConfirmations;
	}

	// modifiers
	modifier onlyWallet() {
		require(msg.sender == address(this), "Not the wallet");
		_;
	}

	modifier ownerExists(address _owner) {
		require(isOwner[_owner], "Not an owner");
		_;
	}
	
	modifier txnExists(uint _txnId) {
		require(_txnId < txnCount, "Txn does not exist");
		_;
	}

	modifier txnNotExists(uint _txnId) {
		require(_txnId >= txnCount, "Txn already exists");
		_;
	}

	modifier confirmed(uint _txnId, address _owner) {
		require(confirmations[_txnId][_owner], "Txn not confirmed");
		_;
	}
	
	modifier notConfirmed(uint _txnId, address _owner) {
		require(!confirmations[_txnId][_owner], "Txn already confirmed");
		_;
	}

	modifier notExecuted(uint _txnId) {
		require(!txns[_txnId].executed, "Txn already executed");
		_;
	}

	modifier notNull(address _address) {
		require(_address != address(0), "Address is null");
		_;
	}

	// receive function allows plain eth deposits
	receive() external payable {
		if (msg.value > 0)
			emit Deposit(msg.sender, msg.value, address(this).balance);
	}

	// constructor
	constructor(address[] memory _owners, uint _required) validRequirement(_owners.length, _required) {
		require(_required <= _owners.length
			&& _required != 0
			&& _owners.length != 0,
			"Invalid requirement");
		
		for (uint i = 0; i < _owners.length; i++) {
			address owner = _owners[i];
			require(!isOwner[owner], "Owner not unique");
			require(owner != address(0), "Invalid owner");
			isOwner[owner] = true;
		}
		owners = _owners;
		required = _required;
	}

	// transaction functions

	// @dev Allows an owner to submit a transaction to the wallet
	function submitTxn(address _to, uint _value, bytes memory _data) public ownerExists(msg.sender) returns (uint _txnId) {
		// adds txn submission to the transaction history
		_txnId = addTxn(_to, _value, _data);
		confirmTxn(_txnId);
	}

	// @dev Internal function to add a new transaction to the transaction mapping
	function addTxn(address _to, uint _value, bytes memory _data) internal notNull(_to) returns (uint _txnId) {
		_txnId = txnCount;
		txns[_txnId] = Transaction({
			to: _to,
			value: _value,
			data: _data,
			executed: false,
			numConfirmations: 0
		});
		txnCount += 1;
		emit Submission(_txnId, msg.sender, _to, _value, _data);
	}

	// @dev Lets an owner confirm a transaction
	function confirmTxn(uint _txnId) public ownerExists(msg.sender) txnExists(_txnId) notConfirmed(_txnId, msg.sender) {
		Transaction storage txn = txns[_txnId];
		txn.numConfirmations += 1;
		confirmations[_txnId][msg.sender] = true;

		emit Confirmation(_txnId, msg.sender);
		executeTxn(_txnId);
	}

	// @dev Revokes a confirmation of a transaction
	function revokeConfirmation(uint _txnId) public ownerExists(msg.sender) txnExists(_txnId) confirmed(_txnId, msg.sender) {
		Transaction storage txn = txns[_txnId];
		txn.numConfirmations -= 1;
		confirmations[_txnId][msg.sender] = false;

		emit Revocation(_txnId, msg.sender);
	}

	// @dev Executes a transaction
	function executeTxn(uint _txnId) public ownerExists(msg.sender) confirmed(_txnId, msg.sender) notExecuted(_txnId) {
		if (isConfirmed(_txnId)) {
			Transaction storage txn = txns[_txnId];
			(bool success, ) = txn.to.call{value: txn.value}(txn.data);
			if (success) {
				txn.executed = true;
				emit Execution(_txnId);
			} else {
				txn.executed = false;
				emit ExecutionFailure(_txnId);
			}
		}
	}
	
	// view functions

	// @dev Returns true if a transaction is confirmed the required number of times
	function isConfirmed(uint _txnId) public view returns (bool) {
		uint count = 0;
		for (uint i = 0; i < owners.length; i++) {
			if (confirmations[_txnId][owners[i]])
				count += 1;
			if (count >= required)
				return true;
		}
	}
	
	// @dev Returns all the owners
	// @return List of owner addresses
	function getOwners() public view returns (address[] memory){
		return owners;
	}
	
	// @dev Returns the number of confirmations of a txn id
	// @param _txnId The transaction id
	// @return count The number of confirmations
	function getConfirmationCount(uint _txnId) public view returns (uint count) {
		count = 0;
		for (uint i = 0; i < owners.length; i++)
			if (confirmations[_txnId][owners[i]])
				count++;
	}

	// @param pending Inlcude pendings txns
	// @param executed Include executed txns
	function getTransactionCount(bool pending, bool executed) public view returns (uint count) {
		for (uint i = 0; i < txnCount; i++) {
			if (pending && !txns[i].executed
				|| executed && txns[i].executed)
				count += 1;
		}
		return count;
	}
	
	// @dev Returns all the addresses that have confirmed the transaction
	// @param _txnId The transaction id
	// @return _confirmations The addresses that have confirmed the transaction
	function getConfirmations(uint _txnId) public view returns (address[] memory _confirmations) {
		address[] memory confirmationsTemp = new address[](owners.length);
		uint count = 0;
		uint i;
		// loops through owners and counts the number of confirmations for the txn
		for (i = 0; i < owners.length; i++)
			if (confirmations[_txnId][owners[i]]) {
				confirmationsTemp[count] = owners[i];
				count += 1;
			}
		
		_confirmations = new address[](count);
		// adds addresses who have confirmed the txn to the new array
		for (i = 0; i < count; i++)
			_confirmations[i] = confirmationsTemp[i];
	}

	// @dev Returns all the transaction ids in the range
	// @param from Index start position of txn array
	// @param to Index end position of txn array
	// @param pending Include pending transactions
	// @param executed Include executed transactions
	// @return _txnIds The transaction ids
	function getTransactionIds(uint from, uint to, bool pending, bool executed) public view returns (uint[] memory _txnIds) {
		require(from < to, "Invalid range");

		// prevent out of bounds access in for loop
		if (to > txnCount)
			to = txnCount;

		uint[] memory txnIdsTemp = new uint[](to - from);
		uint count = 0;
		uint i;
		// loops through txns and adds filtered txns to txnIdsTemp
		for (i = from; i < to; i++) {
			if (pending && !txns[i].executed
				|| executed && txns[i].executed) {
				txnIdsTemp[count] = i;
				count += 1;
			}
		}
		_txnIds = new uint[](count);
		// adds filtered txns to the new array
		for (i = 0; i < count; i++)
			_txnIds[i] = txnIdsTemp[i];
	}
}