Open In App

What is MultiSignature Wallets?

Last Updated : 10 Jan, 2023
Improve
Improve
Like Article
Like
Save
Share
Report

Digital wallets are financial accounts that store funds and make transactions. You can also track transaction histories. Instead of using rupees, dollars we can also use cryptocurrencies such as Bitcoin, Ether, and many more. Generally, a wallet needs one signature to sign a transaction. We can also have multisignature wallets whose access is shared with more than one member i.e we can have copayers. To simplify it, to complete a transaction successfully, we need more than one signature. It has the functionality of setting a minimum signature count i.e the minimum number of signatures required to complete the transaction.

For example, A multisignature wallet has 10000 tokens and is shared between Ajay, Neha, and Amit and the minimum signature count is 2. So at least two of them need to sign a transaction for it to become successful.

This concept is not new. It has been used to secure assets for quite some time but digital implementation has been done recently. For example, limited access such as partial keys of a locker was given earlier to protect the assets.

Examples

  1. Armory: It is an open-source wallet without any need for third-party servers. A maximum of seven authorized signers can be a part of a single wallet. It is available for Windows, Linux, and Mac Desktops.
  2. Electrum: It is an open-source and one of the most popular multisignature wallets. It can easily be integrated with third-party hardware wallets such as Ledger and Trezor. A maximum of fifteen authorized signers can be a part of a single wallet. It is available for Windows, Linux, and Mac Desktops as well as on mobile.
  3. BitPay: It is an open-source wallet without any need for third-party servers. A maximum of three authorized signers can be a part of a single wallet. It is available for Windows, Linux, and Mac Desktops as well as on mobile. We also have a Chrome extension for this.
  4. FreeWallet: It is not an open-source wallet. A maximum of three authorized signers can be a part of a single wallet. It is available for iOS, Android, and through our browsers.

There are several other wallets such as Carbon, BitGo, Casa, and many more and you can choose one depending on your need.

Applications

  1. It can be used in escrow transactions. An escrow transaction is when assets are held by a third party on behalf of other participating parties till all the obligations are met.
  2. It can be used in companies having large funds. It can be used when the board of directors wants to control access to funds.

Advantages

  1. It creates an additional layer of security. Even if one of the owners loses it’s key it is still safe.
  2. It also helps in preventing the misuse of funds.
  3. It can also be used by a single person to add security. He/She can keep both the keys separately. So even if one of them is lost it can still be accessed.

Disadvantages

  1. Setting up a multisignature wallet requires some technical information, particularly on the off chance that you would prefer not to depend on third-party providers.
  2. Since blockchain and multisignature wallets are both relatively new, it might be hard to look for a legal alternative if something goes wrong.

 

How can we code for a multi-signature wallet in solidity?

There are a lot of ways we can code a multi signature wallet.

To begin, let’s define the requirements for our multi-signature wallet. Our wallet will have a fixed number of co-signers, each with its unique private key. A transaction will only be authorized if a certain number of these co-signers approve. For example, if our wallet has 3 co-signers and requires 2 approvals for a transaction to be made, then at least 2 of the 3 co-signers must provide their private key to authorize the transaction.
Now that we have defined the requirements for our wallet let’s start implementing it in Solidity.
First, we will define the struct for our co-signer. This struct will store the address and the public key of the co-signer.
 

Solidity




struct CoSigner {
   address address;
   bytes32 publicKey;
}


Next, we will define the multi signature wallet contract. This contract will store the array of co-signers, the required number of approvals for a transaction, and the current number of approvals for the current transaction.

Solidity




contract MultiSigWallet {
   CoSigner[] public coSigners;
   uint public requiredApprovals;
   uint public currentApprovals;
   constructor(uint _requiredApprovals, CoSigner[] memory _coSigners) public {
       requiredApprovals = _requiredApprovals;
       coSigners = _coSigners;
   }
   // Other functions will go here
}


 

Now, let’s define the function that allows a co-signer to add their approval to the current transaction. This function will take in the co-signer’s address and their signed message as parameters. It will then verify the signature using the co-signer’s public key, and if the signature is valid, it will add the co-signer’s approval to the current transaction.

 

Solidity




function addApproval(address _coSigner, bytes memory _signedMessage) public {
   // Verify the signature using the co-signer's public key
   require(verifySignature(_coSigner, _signedMessage, coSigners[_coSigner].publicKey), "Invalid signature");
   // Add the co-signer's approval to the current transaction
   currentApprovals++;
}


 

We also need to define the function that allows a co-signer to revoke their approval from the current transaction. This function will simply decrease the current number of approvals by one.

 

Solidity




function revokeApproval(address _coSigner) public {
   // Decrease the current number of approvals
   currentApprovals--;
}


 

Finally, we will define the function executing the transaction if the required approvals have been reached. This function will take in the destination address and the transfer value as parameters. It will then send the specified amount.

 

 

Below is the complete code for a multi signature wallet:

 

Solidity




// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MultiSigWallet {
   uint256 start;
   uint256 end;
   modifier timeIsOver() {
       require(block.timestamp <= end, "Time is up");
       _;
   }
   function startTimer() private returns (uint256) {
       start = block.timestamp;
       return start;
   }
   function endTimer(uint256 period) public {
       end = period + startTimer();
   }
   function timeLeft() public view returns (uint256) {
       return end - block.timestamp;
   }
   event Deposit(address indexed sender, uint256 amount, uint256 balance);
   event SubmitTransactionDetails(
       address indexed owner,
       uint256 indexed txIndex,
       address indexed to,
       uint256 value,
       bytes data
   );
   event ConfirmTransaction(address indexed owner, uint256 indexed txIndex);
   event RevokeConfirmation(address indexed owner, uint256 indexed txIndex);
   event ExecuteTransaction(address indexed owner, uint256 indexed txIndex);
   address[] public owners;
   mapping(address => bool) public isOwner;
   uint256 public numConfirmationsRequired;
   struct Transaction {
       address to;
       uint256 value;
       bytes data;
       bool executed;
       uint256 numConfirmations;
   }
   // mapping from tx index => owner => bool
   mapping(uint256 => mapping(address => bool)) public isConfirmed;
   Transaction[] public transactions;
   modifier onlyOwner() {
       require(isOwner[msg.sender], "not owner");
       _;
   }
   modifier txExists(uint256 _txIndex) {
       require(_txIndex < transactions.length, "tx does not exist");
       _;
   }
   modifier notExecuted(uint256 _txIndex) {
       require(!transactions[_txIndex].executed, "tx already executed");
       _;
   }
   modifier notConfirmed(uint256 _txIndex) {
       require(!isConfirmed[_txIndex][msg.sender], "tx already confirmed");
       _;
   }
   constructor(address[] memory _owners) {
       require(_owners.length > 0, "owners required");
       for (uint256 i = 0; i < _owners.length; i++) {
           address owner = _owners[i];
           require(owner != address(0), "invalid owner");
           require(!isOwner[owner], "owner not unique");
           isOwner[owner] = true;
           owners.push(owner);
       }
       numConfirmationsRequired = (_owners.length * 6) / 10 + 1;
   }
   receive() external payable {
       emit Deposit(msg.sender, msg.value, address(this).balance);
   }
   function submitTransaction(
       address _to,
       uint256 _value,
       bytes memory _data,
       uint256 timelock
   ) public onlyOwner {
       uint256 txIndex = transactions.length;
       transactions.push(
           Transaction({
               to: _to,
               value: _value,
               data: _data,
               executed: false,
               numConfirmations: 0
           })
       );
       endTimer(timelock);
       emit SubmitTransactionDetails(msg.sender, txIndex, _to, _value, _data);
   }
   function confirmTransaction(uint256 _txIndex)
       public
       onlyOwner
       txExists(_txIndex)
       notExecuted(_txIndex)
       notConfirmed(_txIndex)
   {
       Transaction storage transaction = transactions[_txIndex];
       transaction.numConfirmations += 1;
       isConfirmed[_txIndex][msg.sender] = true;
       emit ConfirmTransaction(msg.sender, _txIndex);
   }
   function executeTransaction(uint256 _txIndex)
       public
       onlyOwner
       timeIsOver
       txExists(_txIndex)
       notExecuted(_txIndex)
   {
       Transaction storage transaction = transactions[_txIndex];
       require(
           transaction.numConfirmations >= numConfirmationsRequired,
           "cannot execute tx"
       );
       transaction.executed = true;
       (bool success, ) = transaction.to.call{value: transaction.value}(
           transaction.data
       );
       require(success, "tx failed");
       emit ExecuteTransaction(msg.sender, _txIndex);
   }
   function revokeConfirmation(uint256 _txIndex)
       public
       onlyOwner
       txExists(_txIndex)
       notExecuted(_txIndex)
   {
       Transaction storage transaction = transactions[_txIndex];
       require(isConfirmed[_txIndex][msg.sender], "tx not confirmed");
       transaction.numConfirmations -= 1;
       isConfirmed[_txIndex][msg.sender] = false;
       emit RevokeConfirmation(msg.sender, _txIndex);
   }
   function getOwners() public view returns (address[] memory) {
       return owners;
   }
   function getTransactionCount() public view returns (uint256) {
       return transactions.length;
   }
   function getTransaction(uint256 _txIndex)
       public
       view
       returns (
           address to,
           uint256 value,
           bytes memory data,
           bool executed,
           uint256 numConfirmations
       )
   {
       Transaction storage transaction = transactions[_txIndex];
       return (
           transaction.to,
           transaction.value,
           transaction.data,
           transaction.executed,
           transaction.numConfirmations
       );
   }
}


 

 

Here is a brief overview of the main features and functions in the contract:

  1. Timelock: The startTimer, endTimer, and timeIsOver functions and variables are used to implement a timelock feature. The startTimer function sets the start variable to the current block’s timestamp, and the endTimer function sets the end variable to the sum of the desired timelock period and the start variable. The timeIsOver modifier is used to ensure that a function can only be called if the current block’s timestamp is within the timelock period.
  2. Transactions: The Transaction struct stores information about a transaction, such as a recipient address, the number of funds to be transferred, and any associated data. The transactions array stores all the transactions submitted to the wallet.
  3. Confirmations: The numConfirmationsRequired variable stores the number of confirmations required for a transaction to be executed. The isConfirmed mapping is used to track the confirmations received for each transaction. An owner can confirm or revoke their confirmation for a transaction using the confirmTransaction and revokeConfirmation functions.
  4. Executing transactions: The executeTransaction function is used to execute a transaction if the required number of confirmations have been received and the timelock period has not expired. The notExecuted and timeIsOver modifiers are used to ensure that a transaction is not executed multiple times and that it is executed within the timelock period, respectively.
  5. Access control: The onlyOwner modifier is used to ensure that only owners can call certain functions. The isOwner mapping is used to track which addresses are owners.

Here is a detailed explanation of the above code:

Solidity




// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./AccessRegistry.sol";
contract MultiSigWallet {
  uint256 start;
  uint256 end;
  modifier timeIsOver() {
      require(block.timestamp <= end, "Time is up");
      _;
  }


The start and end variables store the start and end times for the timelock period, respectively. The timeIsOver modifier ensures that a function can only be called if the current block’s timestamp is within the timelock period.
 

Solidity




function startTimer() private returns (uint256) {
    start = block.timestamp;
    return start;
}
function endTimer(uint256 period) public {
    end = period + startTimer();
}
function timeLeft() public view returns (uint256) {
    return end - block.timestamp;
}


The startTimer function sets the start variable to the current block’s timestamp, and the endTimer function sets the end variable to the sum of the desired timelock period and the start variable. The timeLeft function returns the time left in the timelock period.

Solidity




event Deposit(address indexed sender, uint256 amount, uint256 balance);
event SubmitTransactionDetails(
    address indexed owner,
    uint256 indexed txIndex,
    address indexed to,
    uint256 value,
    bytes data
);
event ConfirmTransaction(address indexed owner, uint256 indexed txIndex);
event RevokeConfirmation(address indexed owner, uint
 
 
address[] public owners;
mapping(address => bool) public isOwner;
uint256 public numConfirmationsRequired;
struct Transaction {
    address to;
    uint256 value;
    bytes data;
    bool executed;
    uint256 numConfirmations;
}
// mapping from tx index => owner => bool
mapping(uint256 => mapping(address => bool)) public isConfirmed;
Transaction[] public transactions;


The owners array stores the addresses of the owners of the wallet. The isOwner mapping is used to track which addresses are owners. The numConfirmationsRequired variable stores the number of confirmations required for a transaction to be executed. The Transaction struct stores information about a transaction, such as a recipient address, the number of funds to be transferred, and any associated data. The transactions array stores all the transactions submitted to the wallet. The isConfirmed mapping is used to track the confirmations received for each transaction.

Solidity




modifier onlyOwner() {
    require(isOwner[msg.sender], "not owner");
    _;
}
modifier txExists(uint256 _txIndex) {
    require(_txIndex < transactions.length, "tx does not exist");
    _;
}
modifier notExecuted(uint256 _txIndex) {
    require(!transactions[_txIndex].executed, "tx already executed");
    _;
}
modifier notConfirmed(uint256 _txIndex) {
    require(!isConfirmed[_txIndex][msg.sender], "tx already confirmed");
    _;
}


 

The onlyOwner modifier ensures that only owners can call certain functions. The txExists modifier ensures that a transaction with the specified index exists. The notExecuted modifier is used to ensure that a transaction has not already been executed. The notConfirmed modifier is used to ensure that the calling address has not already confirmed a transaction.

Solidity




constructor(address[] memory _owners) {
     require(_owners.length > 0, "owners required");
     for (uint256 i = 0; i < _owners.length; i++) {
         address owner = _owners[i];
         require(owner != address(0), "invalid owner");
         require(!isOwner[owner], "owner not unique");
         isOwner[owner] = true;
         owners.push(owner);
     }
     numConfirmationsRequired = (_owners.length * 6) / 10 + 1;
 }


The constructor is used to initialize the wallet. It takes an array of owner addresses as input and stores them in the owner’s array. It also sets the isOwner mapping for each owner address and calculates the number of confirmations required for a transaction to be executed.

Solidity




receive() external payable {
     emit Deposit(msg.sender, msg.value, address(this).balance);
 }


The receive function is the fallback function for the contract. It allows the contract to receive funds from external sources. It emits the Deposit event to track the incoming funds.
 

Solidity




function submitTransaction(
    address _to,
    uint256 _value,
    bytes memory _data,
    uint256 timelock
) public onlyOwner {
    uint256 txIndex = transactions.length;
    transactions.push(
        Transaction({
            to: _to,
            value: _value,
            data: _data,
            executed: false,
            numConfirmations: 0
        })
    );
    endTimer(timelock);
    emit SubmitTransactionDetails(msg.sender, txIndex, _to, _value, _data);
}


The submitTransaction function is used to submit a new transaction to the wallet. It takes the recipient address, the transfer value, and associated data as input. It also takes a timelock period as input, which determines how long the transaction will be open for confirmations. The function adds a new transaction to the transactions array and sets the end variable to the sum of the timelock period and the start variable. It also emits the SubmitTransactionDetails event to track the submission of the transaction.

Solidity




function confirmTransaction(uint256 _txIndex)
    public
    onlyOwner
    txExists(_txIndex)
    notExecuted(_txIndex)
    notConfirmed(_txIndex)
{
    Transaction storage transaction = transactions[_txIndex];
    transaction.numConfirmations += 1;
    isConfirmed[_txIndex][msg.sender] = true;
    emit ConfirmTransaction(msg.sender, _txIndex);
}


The confirmTransaction function is used to confirm a transaction. It takes the index of the transaction as input and increments the numConfirmations field in the Transaction struct. It also sets the isConfirmed mapping for the transaction and the calling address to true. The function is restricted to only owners, and it checks that the transaction exists, has not been executed, and has not already been confirmed by the calling address. It emits the ConfirmTransaction event to track the confirmation of the transaction.

Solidity




function executeTransaction(uint256 _txIndex)
    public
    onlyOwner
    timeIsOver
    txExists(_txIndex)
    notExecuted(_txIndex)
{
    Transaction storage transaction = transactions[_txIndex];
    require(
        transaction.numConfirmations >= numConfirmationsRequired,
        "cannot execute tx"
    );
    transaction.executed = true;
    (bool success, ) = transaction.to.call.value(transaction.value)(transaction.data);
    require(success, "call failed");
    emit ExecuteTransaction(msg.sender, _txIndex);
}


The executeTransaction function is used to execute a transaction. It takes the index of the transaction as input and checks that the transaction has received enough confirm

However, we are free to add several functionalities to the contract. Here are some of the extra functionalities we can add to the contract. Please take it as an exercise.

  1. Transaction queue: You can add a queue of transactions waiting for approvals. This will allow co-signers to add their approvals for multiple transactions at once rather than waiting for each transaction to be executed before adding their approval for the next one.
  2. Nonce system: You can add a nonce system to prevent replay attacks. A replay attack is when a transaction is copied and broadcasted again, potentially causing it to be executed multiple times. A nonce is a unique number that is incremented with each transaction, and it can be included in the signed message to ensure that each transaction is only executed once.
  3. Emergency stop: You can add an emergency stop function that allows one co-signer to halt all transactions until the emergency is resolved. This can be useful in cases where attackers are targeting the wallet or if a contract bug needs to be fixed.
  4. Daily limit: You can add a daily limit to the number of funds transferred from the wallet. This can be useful in preventing large, unauthorized transfers from occurring.
    Whitelist: You can add a whitelist of addresses that are allowed to receive funds from the wallet. This can be useful to prevent funds from being sent to addresses that are not intended to receive them.
     


Like Article
Suggest improvement
Share your thoughts in the comments

Similar Reads