🎓 A1Learner
🔍
0 of 48 lessons completed

Solidity Project: Multisig Wallet

A multisig wallet is a wallet that moves ether only after enough of its owners approve the move. No single owner can spend the wallet funds alone.

An owner submits a proposed move

An owner submits a move of 1 ether to a recipient. The wallet records one transaction with zero approvals.

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

contract MultisigWallet {
    address[] public owners;
    mapping(address => bool) public isOwner;
    uint256 public threshold;

    struct Tx {
        address to;
        uint256 value;
        bool executed;
        uint256 approvals;
    }

    Tx[] public txs;
    mapping(uint256 => mapping(address => bool)) public approved;

    receive() external payable {}

    constructor(address[] memory initialOwners, uint256 required) {
        require(initialOwners.length >= required, "threshold above owner count");
        require(required > 0, "threshold must be above zero");
        for (uint256 i = 0; i < initialOwners.length; i++) {
            require(initialOwners[i] != address(0), "no zero owner");
            require(!isOwner[initialOwners[i]], "duplicate owner");
            isOwner[initialOwners[i]] = true;
        }
        owners = initialOwners;
        threshold = required;
    }

    modifier onlyOwner() {
        require(isOwner[msg.sender], "only owners act");
        _;
    }

    function submit(address to, uint256 value) public onlyOwner returns (uint256) {
        txs.push(Tx(to, value, false, 0));
        return txs.length - 1;
    }

    function approve(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        require(!txs[id].executed, "already executed");
        require(!approved[id][msg.sender], "already approved");
        approved[id][msg.sender] = true;
        txs[id].approvals += 1;
    }

    function execute(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        Tx storage t = txs[id];
        require(!t.executed, "already executed");
        require(t.approvals >= threshold, "not enough approvals");
        t.executed = true;
        (bool ok, ) = t.to.call{value: t.value}("");
        require(ok, "payout failed");
    }

    function txCount() public view returns (uint256) {
        return txs.length;
    }
}

Distinct owners approve, one owner cannot approve twice

One owner approval raises the count to one. The same owner approving again reverts, while a second owner raises it to two.

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

contract MultisigWallet {
    address[] public owners;
    mapping(address => bool) public isOwner;
    uint256 public threshold;

    struct Tx {
        address to;
        uint256 value;
        bool executed;
        uint256 approvals;
    }

    Tx[] public txs;
    mapping(uint256 => mapping(address => bool)) public approved;

    receive() external payable {}

    constructor(address[] memory initialOwners, uint256 required) {
        require(initialOwners.length >= required, "threshold above owner count");
        require(required > 0, "threshold must be above zero");
        for (uint256 i = 0; i < initialOwners.length; i++) {
            require(initialOwners[i] != address(0), "no zero owner");
            require(!isOwner[initialOwners[i]], "duplicate owner");
            isOwner[initialOwners[i]] = true;
        }
        owners = initialOwners;
        threshold = required;
    }

    modifier onlyOwner() {
        require(isOwner[msg.sender], "only owners act");
        _;
    }

    function submit(address to, uint256 value) public onlyOwner returns (uint256) {
        txs.push(Tx(to, value, false, 0));
        return txs.length - 1;
    }

    function approve(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        require(!txs[id].executed, "already executed");
        require(!approved[id][msg.sender], "already approved");
        approved[id][msg.sender] = true;
        txs[id].approvals += 1;
    }

    function execute(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        Tx storage t = txs[id];
        require(!t.executed, "already executed");
        require(t.approvals >= threshold, "not enough approvals");
        t.executed = true;
        (bool ok, ) = t.to.call{value: t.value}("");
        require(ok, "payout failed");
    }

    function txCount() public view returns (uint256) {
        return txs.length;
    }
}

Execution at threshold moves the ether

With two approvals the move executes and the recipient balance rises by 1 ether. The transaction is marked executed.

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

contract MultisigWallet {
    address[] public owners;
    mapping(address => bool) public isOwner;
    uint256 public threshold;

    struct Tx {
        address to;
        uint256 value;
        bool executed;
        uint256 approvals;
    }

    Tx[] public txs;
    mapping(uint256 => mapping(address => bool)) public approved;

    receive() external payable {}

    constructor(address[] memory initialOwners, uint256 required) {
        require(initialOwners.length >= required, "threshold above owner count");
        require(required > 0, "threshold must be above zero");
        for (uint256 i = 0; i < initialOwners.length; i++) {
            require(initialOwners[i] != address(0), "no zero owner");
            require(!isOwner[initialOwners[i]], "duplicate owner");
            isOwner[initialOwners[i]] = true;
        }
        owners = initialOwners;
        threshold = required;
    }

    modifier onlyOwner() {
        require(isOwner[msg.sender], "only owners act");
        _;
    }

    function submit(address to, uint256 value) public onlyOwner returns (uint256) {
        txs.push(Tx(to, value, false, 0));
        return txs.length - 1;
    }

    function approve(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        require(!txs[id].executed, "already executed");
        require(!approved[id][msg.sender], "already approved");
        approved[id][msg.sender] = true;
        txs[id].approvals += 1;
    }

    function execute(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        Tx storage t = txs[id];
        require(!t.executed, "already executed");
        require(t.approvals >= threshold, "not enough approvals");
        t.executed = true;
        (bool ok, ) = t.to.call{value: t.value}("");
        require(ok, "payout failed");
    }

    function txCount() public view returns (uint256) {
        return txs.length;
    }
}

Below threshold execution reverts, and a non-owner cannot approve

One approval is short of the threshold of two, so execution reverts. A stranger approving the move also reverts.

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

contract MultisigWallet {
    address[] public owners;
    mapping(address => bool) public isOwner;
    uint256 public threshold;

    struct Tx {
        address to;
        uint256 value;
        bool executed;
        uint256 approvals;
    }

    Tx[] public txs;
    mapping(uint256 => mapping(address => bool)) public approved;

    receive() external payable {}

    constructor(address[] memory initialOwners, uint256 required) {
        require(initialOwners.length >= required, "threshold above owner count");
        require(required > 0, "threshold must be above zero");
        for (uint256 i = 0; i < initialOwners.length; i++) {
            require(initialOwners[i] != address(0), "no zero owner");
            require(!isOwner[initialOwners[i]], "duplicate owner");
            isOwner[initialOwners[i]] = true;
        }
        owners = initialOwners;
        threshold = required;
    }

    modifier onlyOwner() {
        require(isOwner[msg.sender], "only owners act");
        _;
    }

    function submit(address to, uint256 value) public onlyOwner returns (uint256) {
        txs.push(Tx(to, value, false, 0));
        return txs.length - 1;
    }

    function approve(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        require(!txs[id].executed, "already executed");
        require(!approved[id][msg.sender], "already approved");
        approved[id][msg.sender] = true;
        txs[id].approvals += 1;
    }

    function execute(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        Tx storage t = txs[id];
        require(!t.executed, "already executed");
        require(t.approvals >= threshold, "not enough approvals");
        t.executed = true;
        (bool ok, ) = t.to.call{value: t.value}("");
        require(ok, "payout failed");
    }

    function txCount() public view returns (uint256) {
        return txs.length;
    }
}

Example

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

contract MultisigWallet {
    address[] public owners;
    mapping(address => bool) public isOwner;
    uint256 public threshold;

    struct Tx {
        address to;
        uint256 value;
        bool executed;
        uint256 approvals;
    }

    Tx[] public txs;
    mapping(uint256 => mapping(address => bool)) public approved;

    receive() external payable {}

    constructor(address[] memory initialOwners, uint256 required) {
        require(initialOwners.length >= required, "threshold above owner count");
        require(required > 0, "threshold must be above zero");
        for (uint256 i = 0; i < initialOwners.length; i++) {
            require(initialOwners[i] != address(0), "no zero owner");
            require(!isOwner[initialOwners[i]], "duplicate owner");
            isOwner[initialOwners[i]] = true;
        }
        owners = initialOwners;
        threshold = required;
    }

    modifier onlyOwner() {
        require(isOwner[msg.sender], "only owners act");
        _;
    }

    function submit(address to, uint256 value) public onlyOwner returns (uint256) {
        txs.push(Tx(to, value, false, 0));
        return txs.length - 1;
    }

    function approve(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        require(!txs[id].executed, "already executed");
        require(!approved[id][msg.sender], "already approved");
        approved[id][msg.sender] = true;
        txs[id].approvals += 1;
    }

    function execute(uint256 id) public onlyOwner {
        require(id < txs.length, "no such transaction");
        Tx storage t = txs[id];
        require(!t.executed, "already executed");
        require(t.approvals >= threshold, "not enough approvals");
        t.executed = true;
        (bool ok, ) = t.to.call{value: t.value}("");
        require(ok, "payout failed");
    }

    function txCount() public view returns (uint256) {
        return txs.length;
    }
}

Key points

  • Multisig: a wallet needing several owner approvals before it spends.
  • Owner: one of the addresses allowed to submit, approve, and execute.
  • Threshold: the number of distinct owner approvals a move needs.
  • Transaction: a proposed move with a recipient, an amount, and an approval count.
  • Approval: one owner backing a single proposed move, counted once per owner.
  • Execution: sending the move once its approvals reach the threshold.

In this example, three owners share a wallet with a threshold of two. Funding the wallet, submitting a move, approving it with two distinct owners, and executing it moves exactly 1 ether to the recipient, and the same move cannot execute a second time.

Note

the toy wallet moves ether only, while a full wallet also carries call data for other contracts.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

A multisig wallet has three owners and a threshold of two. How many distinct owner approvals must a move gather before it executes?