- Published on
Coding a multisig wallet with solidity
- Authors
- Name
- Killian Godfrey
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 addressesrequired
how many signatures are required to send a transactiontxnCount
total # of transactionsisOwner
a mapping to track if an address is an owner or not Note that all our state variables arepublic
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 withaddTxn
andconfirmTxn
functionsaddTxn
- an internal function, it records the transaction info in ourtxns
mapping, effectively adding it to our transaction history/logconfirmTxn
- 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 callsexecuteTxn
revokeConfirmation
- a very similar function toconfirmTxn
, however instead will revoke their confirmation, effectively undoingconfirmTxn
isConfirmed
- tells us if the required number of confirmations have been met for a transactionexecuteTxn
- checksisConfirmed
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];
}
}