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?