Understanding Multi-sig Wallet

Multisig wallet requires more than one signature to authorize transactions adding an extra layer of security to stored funds. The signatures are associated with different cryptographic private keys. Any holder of a multi-sig wallet can initiate a signed transaction with their private key, but this transaction would be pending until it passes/equals the required confirmations.

There’re multiple types of Multisig wallets but two popular ones, one requires all parties to sign off on the transaction before it's confirmed:

N-N, all signatories must be confirmed before the transaction would be validated, usually two signers; where “N” denotes the number of signers.

M-N, a predefined number of signers from the pool must be met to confirm a transaction, where “N” denotes the total number of signers and “M” denotes the required number of signatures to validate a transaction

Multisig wallets are different from traditional wallets as they spread access across multiple keys to prevent easy loss of funds. Traditional wallets are also known as Externally Owned Accounts, they’re less secure and are controlled by private keys generated by the owners, and in combination with a public address, they’re used to communicate with the blockchain, unlike Multisig wallets, traditional wallets or single-key wallets are good for smaller and faster transactions, Mulitsig wallets, on the other hand, are good for joint control over funds in split accounts, eases transparency in decentralized organizations (DAOs-Decentralized Autonomous Organizations) and strengthens security for users with a considerable amount of funds.

The design structure of Multisig wallets reduces the risk of compromise by distributing the signing authority thereby eliminating a single point of failure or key-person risk usually associated with Externally Owned Accounts. Key person risk refers to when a company relies almost entirely on a single individual to succeed. This risk is all too common in crypto, particularly in instances where one individual is in control of a wallet’s seed phrase.” Many blockchains integrate functionality that enables users to implement multi-signature wallets. Cryptocurrency exchanges also implement Multisig wallets and store associated private keys in diverse locations to protect client assets.

Benefits of Multisig

Improved security and transparency as keys are spread across several locations and devices, reducing the dependency on a single party. No key person risk. Serves as two-factor authentication.

How does it work?

Multisig wallets require two or more signatures for a transaction to be validated, during set-up, the signatories set the access rules, including the required minimum number of keys for the execution of a task in the N-M setup, or if all keys are required for validation N-N. Multisig wallets use smart contracts for on-chain governance.

The signers generate the private-public pairs using a cryptographic algorithm, the combination of both keys creates a Multisig address associated with the wallet.

Users then set the confirmation requirements, which could either be N-N or N-M as explained above.

When a party initiates a transaction, the transaction remains pending till the signatory requirements are fulfilled, m then the transaction is sent to the network for verification upon which is confirmed.

Let's write a minimalistic contract for a multi-sig wallet. network for verification upon which is confirmed.

Contract MultisigWallet

We define an array of addresses owners to store all the signatories to the wallet

Address [] public owners

Then we define a mapping of the owners to check if an invalid address is trying to submit a transaction

Mapping (address=>bool) public isOwners;
Uint public required

the required variable keeps track of the number of predefined signatures needed to validate a transaction, this will be set up in the constructor.

We define a struct that contains the details of the proposed transaction, we name it Transaction, with 4 values address to, unit value, bytes data, bool executed:

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

Address to: is the address of the recipient

Uint value: the amount to be sent

Bytes data: is the data of the transaction

Bool executed: tracks if the transaction has been executed or not.

Then we define an array of the transaction struct to store all the transactions.

 Transactions [] public transaction

Next, we create a mapping of the index of each transaction to their address and to a bool of their approval

mapping (uint => mapping(address=>bool)) public approved;

Next, we set our constructor, which takes an array of owners, and a uint require. First, we check if we’ve any owner in the array, then we check if the required is greater than zero and not more than the number of owners. Then we push all the owners passed in into the owners state variable, by running a for loop, we first check if the addresses are not equal to the zeroeth address, then we check if the owners aren’t already in the owners array, then we push into the owners array and update the mapping of isOwners . Finally, we set the input required to the state variable requirerequired=_required; and that's it, we’re done with the constructor.

 constructor(address[] memory _owners, uint _required) {
        if (_owners.length <= 0) {
            revert MultsigWallet_OwnersRequired();
        }

        require(
            _required > 0 && _required <= _owners.length,
            "Invalid required number of owners"
        );
        // we pushing all owners inside the owners state variable
        for (uint i; i < _owners.length; i++) {
            address owner = _owners[i];
            require(owner != address(0), "invalid owner");
            require(!isOwners[owner], "Owner isnt unique");

            //we inset the new owners inside the owners mapping and the owners array
            isOwners[owner] = true;
            owners.push(owner);
        }
        //setting the required to the required from the input.
        required = _required;
    }

Now we enable this contract to receive money, then we emit an event that takes In the sender and the amount received.

 //we setting the wallet to be able to recieve ether
    receive() external payable {
        emit Deposit(msg.sender, msg.value);
    }

    event Deposit(address indexed sender, uint amount);

We’ve five functions, four external and one internal function, but first, let's declare our modifier, we’ve four modifiers and they are all self-explanatory. We make sure onlyOwner can approve a transaction, we make sure the transaction exists, txExist, we make sure it hasn’t been approved by this address yet, notApproved, and it hasn’t been executed yet, notExecuted. Then we access the approved mapping and set it to true.

modifier onlyOwner() {
        require(isOwners[msg.sender], "Not owner");
        _;
    }
    modifier txExists(uint _txId) {
        // we check if the txId is less than the length
        require(_txId < transactions.length, "invalid transactions ID");
        _;
    }

    modifier notApproved(uint _txId) {
        require(!approved[_txId][msg.sender], "not approved");
        _;
    }
    modifier notExecuted(uint _txId) {
        require(!transactions[_txId].executed, "tx  already executed");
        _;
    }

Submit function, which takes the same input from the transaction structs (_to, value, data) but we add the modifier onlyOwner to ensure only signatories can submit transactions. We then push the input values to the transactions array. We then emit an event of the transaction index. We get the index by deducting 1 from the length of the transaction array. The onlyOwner ensures only signatories to the wallet can submit a transaction,

 function submit(
        address _to,
        uint _value,
        bytes calldata _data
    ) external onlyOwner {
        // we push the submitted transactions to the Transaction struct
        transactions.push(
            Transactions({to: _to, value: _value, data: _data, executed: false})
        );
        // the first transactions stored as index 0, thehn index 1 and so on
        emit Submit(transactions.length - 1);
    }

    event Submit(uint indexed txId);

Then we have the approve function, the approve function takes in the txId as the only param, and it approves the transaction of the transaction Id of the owner(msg.sender), it emits an event

 function approve(
        uint _txId
    ) external onlyOwner txExists(_txId) notApproved(_txId) notExecuted(_txId) {
        approved[_txId][msg.sender] = true;
        emit Approve(msg.sender, _txId);
    }
    event Approve(address indexed owner, uint indexed txId);

Next, we need to get the approvalCount, this is a private function and it takes in the txId as the only param, it returns a uint count, we loop through the owners to get individual owners' addresses, then access their approved and add it to the count variable. Ps, we declare the count in the function declaration to save gas

  function _getApprovalCount(uint _txId) private view returns (uint count) {
        for (uint i; i < owners.length; i++) {
            if (approved[_txId][owners[i]]) {
                count += 1;
            }
        }
    }

Next is the function execute, this as the others can only be called by the owners, this function awaits that the approval count is equal to the required, creates an instance of the transaction, then checks the execute in the transaction to true, and sends the funds to the address _to. Then we emit an event Execute with the txId. Ps; we declare the transaction as storage because we will be updating the transaction array.

 function execute(uint _txId) external txExists(_txId) notExecuted(_txId) {
        require(
            _getApprovalCount(_txId) >= required,
            "appproval count is less than required"
        );
        Transactions storage transaction = transactions[_txId];
        transaction.executed = true;
        (bool success, ) = transaction.to.call{value: transaction.value}(
            transaction.data
        );
        require(success, "transaction failed");
        emit Execute(_txId);
    }

Last, for our contract, we declare the revoke function, this is just in case a user decides to change his mind, the function takes in the (_txId), it checks onlyOwner can perform this function, the txExists and notApproved., then it emits an event with the Approved with the msg.sender and the txId.

function revoke(
        uint _txId
    ) external onlyOwner txExists(_txId) notExecuted(_txId) {
        require(approved[_txId][msg.sender], "tx not apporved");
        approved[_txId][msg.sender] = false;
        emit Revoke(msg.sender, _txId);
    }

Thank you for reading, for the full code check out my GitHub. cc solidity by example.