Creating MultiSig Wallet Contract

Introduction

Ensuring the security of our cryptocurrency holdings is crucial. Multi-sig wallets play a pivotal role in this aspect, providing additional protection against unauthorized access. They require multiple approvals before a transaction can be executed, significantly enhancing security.

Multi-sig wallets are a crucial defense system against hacking, theft, and unauthorized transfers. By distributing control among several parties, they reduce the risk of malicious activities.

In this guide, we'll lead you through creating a multi-sig wallet smart contract using Solidity. The main part of the multi-sig wallet is the contract. After the contract is ready and deployed over the network, one can easily access its features in a GUI interface by simply calling the contract's functions.

Understanding Multisig Wallets

Cryptocurrency security is a critical concern, and multi-sig wallets are a powerful tool in enhancing it. Understanding how they work is essential for anyone looking to secure their digital assets.

Multi-sig wallets

What are Multisig Wallets?

A Multisignature Wallet, also referred to as a Multi-Sig wallet in short, is a type of cryptocurrency wallet that requires multiple signatures or approvals from different individuals or devices before a transaction can be authorized.

Instead of a single private key controlling the wallet, a predefined number of keys (or signatures) are required to validate a transaction. This collaborative method offers an extra degree of protection since it requires the agreement of numerous parties before funds can be transferred.

Advantages of Multi-Sig Wallets

  1. Enhanced Security: Multi-sig wallets minimize the risk of unauthorized access or hacking. Even if one key is compromised, the wallet remains secure as more signatures are required for the funds transfer.
  2. Protection Against Single Point of Failure: Traditional wallets are vulnerable if the private key is lost or stolen. With multisig, even if one key is lost, the wallet remains accessible.
  3. Shared Control: Multisig wallets enable shared control over funds, making them ideal for businesses, organizations, or families managing joint assets.

Creating the Multisig Wallet Smart Contract

After getting to know about the multi-sig wallet, you must now be eager to know about how we can use a multi-sig wallet or better create our own.

When talking about Multisig wallets, we can use many third-party multisig wallets like Gnosis Safe, Guarda, TotalSig, etc. But in this guide, we will be focusing on creating our own Multisig wallet contract rather than using someone else.

After we have created and deployed our multi-sig smart contract, we can simply use the contract's function to sign any transaction and later make it more usable using an interactive UI that calls the contract's function at the backend.

Now, Let's start our journey of creating the wallet contract

Set Up the Development Environment

Before starting with the coding part, we need to set up our development environment for Solidity. Solidity is a programming language that is used for writing smart contracts for Ethereum and Ethereum-based blockchains. 

 You need to choose a code editor or Integrated Development Environment (IDE) like Remix IDE. I would personally suggest you use the Hardhat development environment. 

You can initialize a project with npm and hardhat and start writing your solidity code. To know more about hardhat, refer to the article Smart Contract Deployment Made Easy with Hardhat.

Writing Multi-Sig Smart Contract

With your multi-sig environment ready, it is now time to start writing our multi-sig smart contract

A. Defining Contract structure

A smart contract follows a set contract structure. Start your coding by creating a new solidity file with the name of MultiSig.sol and define the contract's structure 

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

contract MultiSig {
    // Contract's properties and functions will go here
}

Here,

  • SPDX-License-Identifier, tells us about the open-source license the contract is using
  • pragma solidity ^0.8.18, tells about the solidity compiler version being used.
  • contract MultiSIg{...}, is the basic structure of defining a contract. All the properties and functions of the Multisig contract are to be enclosed by the contract named MultiSig. You can use any name you like.

B.  Define the state variables, structures, and mapping variables

Define the necessary state variables for the multi-sig wallet, including the list of approved signers/owners and the required number of approvals.

contract MultiSig {
    address[] public owners;
    uint256 public numConfirmationsRequired;

    //mapping for owner address
    mapping(address => bool) public isOwner;

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

    // mapping from tx index => owner => bool
    mapping(uint256 => mapping(address => bool)) public isTransactionConfirmed;

    Transaction[] public transactions;
}

Here,

  • address[] public owners and uint256 public numConfirmationsRequired, are state variables that are used to store all the owner's addresses as well as the total number of confirmations needed for a transaction to execute.
  • struct Transaction{...} is the structure defining a transaction that is submitted, and all the transactions are stored in an array of Transaction[].
  • and mapping for owner and transaction confirmation is also defined.

C. Define all the events needed in the contract to maintain logs

Events are used in contracts to keep logs of the important actions happening in the contract. We are also going to use events during the submission of a new transaction and execution of a transaction. Let's define them.

//events
event Deposit(address indexed sender, uint256 amount, uint256 balance);
event SubmitTransaction(
    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);

Here, 

  • SubmitTransaction event is invoked whenever a new transaction is submitted by any owner for execution.
  • ConfirmTransaction event is called whenever an owner confirms a transaction.
  • RevokeConfirmation is called whenever a confirmation is revoked from a transaction.
  • ExecuteTransaction is called at the last after a transaction is executed. It stores the log of the transactions that are executed.
  • The deposit event is triggered whenever the Ether amount is deposited in the contract. Whenever a transaction is signed, Ether which is required for signing the transaction, is sent from the deposited Ethers.

D. Define the required modifiers

Modifiers are special functions that allow us to add extra conditions to a function that is reusable.

//modifier for checking only owner
modifier OnlyOwner()  {
    require(isOwner[msg.sender] == true, "Not the owner");
    _;
}

//modifier for checking if transaction exists
modifier txExist(uint256 _index) {
    require(_index < transactions.length, "transaction not exist");
    _;
}

//modifier for checking if transaction is not yet executed
modifier txNotExecuted(uint256 _index) {
    require(transactions[_index].executed == false, "transaction executed");
    _;
}

//modifier to check if an address has approved of the transaction or not
    modifier txNotApproved(uint _index) {
    require(isTransactionConfirmed[_index][msg.sender] == false, "transaction already confirmed");
    _;
}

Here, We are going to use 4 modifiers, 

  • OnlyOwner modifier is used to check whether the msg sender is an owner or not, as only the owners of the contract can submit, confirm or approve a transaction.
  • The txExist modifier checks whether a transaction exists or not. It uses the index number of the transaction to check for the transaction with the array of transactions.
  • The txNotExecuted modifier checks if the transaction is still active, i.e., the transaction hasn't been executed. This check is necessary as real money is involved in the execution of smart contract functions. Therefore we should stop the approval or execution of a transaction if it is already executed.
  • txNotApproved modifier checks whether a particular owner has approved a particular transaction or not.

E. Define the contract's constructor

After defining all the state variables, events, and modifiers, we should start with defining the constructor for our contract. Constructor is the first block of code that gets executed when we deploy our contract.

constructor(address[] memory _owners, uint8 _numConfirmationsRequired) {
    require(_owners.length > 0, "Insufficient Owners");
    require(
        _numConfirmationsRequired > 0 &&
            _numConfirmationsRequired <= _owners.length,
        "invalid number of confirmations"
    );

    for (uint i = 0; i < _owners.length; i++) {
        address owner = _owners[i];

        if (owner == address(0)) {
            revert("Zero address cannot be owner");
        }
        if (isOwner[owner] != true) {
            revert("Owner not unique");
        }

        isOwner[owner] = true;
        owners.push(owner);
    }
}

Here, 

  • We are defining a parametrized constructor that takes in the addresses of the owners in an array and the number of confirmations needed to approve a transaction as parameters.
  • We are checking that the array of owners should contain some addresses in them as well as whether the numConfirmationRequired is valid or not.
  • We later checked the addresses individually, that they were not zero addresses or nether same addresses that are already assigned ownership.
  • At last, we are pushing the owner addresses into the owner's array and setting the isOwner[owner] as true.

F. Define the receive() function to receive ether transfers.

When Ether is directly sent to the contract (sender uses send or transfer) to receive Ether, you have to implement a receive Ether function.

// accepts ether transfers to the contract
receive() external payable {
    emit Deposit(msg.sender, msg.value, address(this).balance);
}

Here, we are defining a receive function as external payable to receive ether deposits to the contract.

G. Define a function to submit a new transaction request

We need to define a function that can be called to submit a new transaction request to the blockchain.

// Submit transaction function
function submitTransaction(
    address _to,
    uint256 _value,
    bytes memory _data
) public OnlyOwner {
    uint256 txIndex = transactions.length;

    transactions.push(
        Transaction({
            to: _to,
            value: _value,
            data: _data,
            executed: false,
            numConfirmations: 1
        })
    );

    emit SubmitTransaction(msg.sender, txIndex, _to, _value, _data);
}

Here, we are taking transaction parameters and pushing them to the transactions array. The transactions array is an array of Transaction which is a structure that we defined earlier

H. Define a function to approve a particular transaction

After submitting a new transaction, other owners need to approve the transaction using their wallets for a particular transaction to execute. To approve the transaction, we need to create a function that marks the approval of a transaction and triggers the approval event.

function approveTransaction(
    uint256 _txIndex
)
    public
    OnlyOwner
    txExist(_txIndex)
    txNotExecuted(_txIndex)
    txNotApproved(_txIndex)
{
    Transaction storage transaction = transactions[_txIndex];
    transaction.numConfirmations += 1;
    isTransactionConfirmed[_txIndex][msg.sender] = true;

    emit ConfirmTransaction(msg.sender, _txIndex);
    if (transaction.numConfirmations >= numConfirmationsRequired) {
        executeTransaction(_txIndex);
    }
}

Here, we are approving a transaction using the transaction index txIndex, which denotes the place of the transaction on the transaction array. After approving the transaction, we are checking the number of confirmations done for the transaction. If it reaches the threshold, then we are calling the executeTransaction, which will execute the transaction.

I. Define the RevokeConfirmation function

We have already defined a function for approving a transaction, but what if an owner wants to revoke the approval that he gave to the transaction by mistake? Let's define a function to revoke the approval of a transaction.

// Revoke Transaction Confirmation
function revokeConfirmation(
    uint256 _txIndex
) public OnlyOwner txExist(_txIndex) txNotExecuted(_txIndex) {
    Transaction storage transaction = transactions[_txIndex];

    require(isTransactionConfirmed[_txIndex][msg.sender], "tx not confirmed");

    transaction.numConfirmations -= 1;
    isTransactionConfirmed[_txIndex][msg.sender] = false;

    emit RevokeConfirmation(msg.sender, _txIndex);
}

Here,  we are first checking if an owner has confirmed/approved the particular transaction that he wants to revoke his approval and then changing the confirmation status of both the transaction and the owner for that transaction.

J.  Define a function to execute the approved transactions

After we have approved for transactions, it's time to execute them. Let's define a function to execute the approved transactions.

// function to execute a transaction
function executeTransaction(
    uint256 _txIndex
) internal OnlyOwner txExist(_txIndex) txNotExecuted(_txIndex) {
    Transaction storage transaction = transactions[_txIndex];

    transaction.executed = true;

    (bool success, ) = transaction.to.call{value: transaction.value}(
        transaction.data
    );
    require(success, "tx failed");

    emit ExecuteTransaction(msg.sender, _txIndex);
}

Here, we are using the call function which enables contract-to-contract communication. Using the transaction value and transaction data we are communicating with the contract.

K. Define other public functions

At last, we can define other public functions that will help us to look at the transaction using the index, view the owners, etc.

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
    );
}

By defining all these functions, we are now done with the Multi-SIg smart contract.

Deploy the Smart Contract

Deploy the smart contract to the desired network using Hardhat or Remix.

After the contract is deployed, you can verify the contract over the block explorer, and using the contract's ABI and address, you can call the various functions of the contract.

We can create an interactive User Interface for our wallet, which will communicate with our smart contract on the backend.

Conclusion

In this article, we've covered the process of creating a multi-signature wallet using Solidity. Multi-signature wallets enhance security by requiring multiple approvals for transactions. By distributing control, they reduce the risk of unauthorized access and hacking. We've provided a step-by-step process from setup to deployment. Stay updated with the latest practices and test on a testnet before deploying on the mainnet. Stay tuned for more blockchain and solidity-related topics.

FAQs

Q 1. What is a multi-signature wallet, and why is it important?

Ans. A multi-signature wallet, or multi-sig wallet, is a type of cryptocurrency wallet that requires multiple signatures or approvals from different individuals or devices before a transaction can be authorized. This collaborative approach adds an extra layer of security, as it mandates the consensus of multiple parties before funds can be transferred. It's crucial for safeguarding digital assets against unauthorized access, hacking, and theft.

Q 2. How does a multi-signature wallet work?

Ans. In a multi-signature wallet, instead of a single private key controlling the wallet, a predefined number of keys (or signatures) are required to validate a transaction. The wallet maintains a list of approved signers (owners) and a specified number of confirmations needed for a transaction to be executed. This way, even if one key is compromised, the wallet remains secure, as more signatures are required for fund transfers.

Q 3. What are some advantages of using a multi-signature wallet?

Ans. Some advantages of a multi-sig wallet.

  • Enhanced Security: Multi-signature wallets significantly reduce the risk of unauthorized access or hacking. Even if one key is compromised, the wallet remains secure because more signatures are needed for a transaction to proceed.
  • Protection Against Single Point of Failure: Traditional wallets are vulnerable if the private key is lost or stolen. With multi-signature wallets, even if one key is lost, the wallet remains accessible.
  • Shared Control: Multi-signature wallets allow shared control over funds, making them ideal for businesses, organizations, or families managing joint assets.

Q 4. Can I use third-party multi-signature wallets instead of creating my own contract?

Ans. Yes, there are several reputable third-party multi-signature wallet providers like Gnosis Safe, Guarda, and TotalSig. These platforms offer user-friendly interfaces for creating and managing multi-signature wallets. However, if you prefer more control and customization, creating your own smart contract, as outlined in this guide, can be a viable option.

Q 5. What are some best practices for securing a multi-signature wallet?

Ans. Some best practices for securing a multi-signature wallet.

  • Regularly update and audit your smart contract code to ensure it aligns with the latest best practices and security standards.
  • Thoroughly test your smart contract on a testnet before deploying it on the mainnet to identify and rectify any potential vulnerabilities.
  • Keep private keys secure, and consider using hardware wallets for added protection.
  • Implement access control mechanisms and consider adding features like time-locking to enhance security further.
  • Stay informed about the latest security threats and vulnerabilities in the blockchain space and adapt your security measures accordingly.


Similar Articles