Why a multi-signature wallet matters

A standard externally owned account can sign and send transactions immediately. That is convenient, but risky when funds are shared or when a contract owner is a team rather than an individual. A multi-signature wallet reduces that risk by requiring a threshold of approvals, such as 2-of-3 or 3-of-5, before a transfer can happen.

This pattern is useful when:

  • multiple founders control a treasury
  • a protocol team needs guarded admin operations
  • a DAO wants transparent on-chain approvals
  • a project wants to reduce single-key compromise risk

Compared with a single-owner wallet, a multi-sig adds process overhead, but the security tradeoff is often worth it.

Design goals for the contract

Before writing code, define the behavior precisely:

  • only approved owners can submit, confirm, revoke, and execute transactions
  • each transaction is uniquely identified and stored on-chain
  • a transaction can only be executed once
  • execution requires a configurable confirmation threshold
  • the wallet can receive ETH and send ETH
  • duplicate confirmations must be prevented

For simplicity, this tutorial focuses on ETH transfers. The same pattern can later be extended to ERC-20 token transfers or arbitrary contract calls.

Contract architecture

The contract will store:

  • a list of owners
  • a confirmation threshold
  • a transaction struct containing destination, value, data, and execution status
  • a confirmation mapping per transaction and owner

This structure gives you a clean separation between transaction metadata and approval state.

Core data model

ComponentPurpose
ownersStores the authorized signers
isOwnerFast lookup to validate caller permissions
requiredConfirmationsThreshold needed to execute
TransactionHolds destination, value, calldata, and status
confirmationsTracks which owners approved which transaction

Implementing the wallet

Below is a complete implementation of a basic multi-signature wallet.

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

contract MultiSigWallet {
    event Deposit(address indexed sender, uint256 amount, uint256 balance);
    event Submission(uint256 indexed txId, address indexed to, uint256 value, bytes data);
    event Confirmation(address indexed owner, uint256 indexed txId);
    event Revocation(address indexed owner, uint256 indexed txId);
    event Execution(uint256 indexed txId);

    address[] public owners;
    mapping(address => bool) public isOwner;
    uint256 public requiredConfirmations;

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

    Transaction[] public transactions;
    mapping(uint256 => mapping(address => bool)) public confirmedBy;

    modifier onlyOwner() {
        require(isOwner[msg.sender], "Not an owner");
        _;
    }

    modifier txExists(uint256 txId) {
        require(txId < transactions.length, "Transaction does not exist");
        _;
    }

    modifier notExecuted(uint256 txId) {
        require(!transactions[txId].executed, "Transaction already executed");
        _;
    }

    modifier notConfirmed(uint256 txId) {
        require(!confirmedBy[txId][msg.sender], "Transaction already confirmed");
        _;
    }

    constructor(address[] memory _owners, uint256 _requiredConfirmations) {
        require(_owners.length > 0, "Owners required");
        require(_requiredConfirmations > 0, "Required confirmations must be > 0");
        require(_requiredConfirmations <= _owners.length, "Invalid confirmation threshold");

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

        requiredConfirmations = _requiredConfirmations;
    }

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

    function submitTransaction(
        address to,
        uint256 value,
        bytes calldata data
    ) external onlyOwner returns (uint256 txId) {
        require(to != address(0), "Invalid destination");

        transactions.push(
            Transaction({
                to: to,
                value: value,
                data: data,
                executed: false,
                confirmations: 0
            })
        );

        txId = transactions.length - 1;
        emit Submission(txId, to, value, data);
    }

    function confirmTransaction(uint256 txId)
        external
        onlyOwner
        txExists(txId)
        notExecuted(txId)
        notConfirmed(txId)
    {
        confirmedBy[txId][msg.sender] = true;
        transactions[txId].confirmations += 1;

        emit Confirmation(msg.sender, txId);
    }

    function revokeConfirmation(uint256 txId)
        external
        onlyOwner
        txExists(txId)
        notExecuted(txId)
    {
        require(confirmedBy[txId][msg.sender], "Transaction not confirmed");

        confirmedBy[txId][msg.sender] = false;
        transactions[txId].confirmations -= 1;

        emit Revocation(msg.sender, txId);
    }

    function executeTransaction(uint256 txId)
        external
        onlyOwner
        txExists(txId)
        notExecuted(txId)
    {
        Transaction storage txn = transactions[txId];
        require(txn.confirmations >= requiredConfirmations, "Not enough confirmations");
        require(address(this).balance >= txn.value, "Insufficient wallet balance");

        txn.executed = true;

        (bool success, ) = txn.to.call{value: txn.value}(txn.data);
        require(success, "Transaction execution failed");

        emit Execution(txId);
    }

    function getOwners() external view returns (address[] memory) {
        return owners;
    }

    function getTransaction(uint256 txId)
        external
        view
        txExists(txId)
        returns (
            address to,
            uint256 value,
            bytes memory data,
            bool executed,
            uint256 confirmations
        )
    {
        Transaction storage txn = transactions[txId];
        return (txn.to, txn.value, txn.data, txn.executed, txn.confirmations);
    }

    function getTransactionCount() external view returns (uint256) {
        return transactions.length;
    }
}

How the contract works

1. Deployment and owner setup

The constructor takes an array of owner addresses and a confirmation threshold. It validates that:

  • at least one owner exists
  • the threshold is greater than zero
  • the threshold is not larger than the number of owners
  • owners are unique and non-zero addresses

This validation prevents broken configurations at deployment time.

2. Receiving funds

The receive() function allows the wallet to accept plain ETH transfers. Every deposit emits an event so off-chain indexers and dashboards can track incoming funds.

3. Submitting a transaction

Any owner can submit a transaction by specifying:

  • to: destination address
  • value: ETH amount to send
  • data: optional calldata

The data field makes the wallet more flexible than a simple transfer-only vault. It can later be used to call another contract, though this tutorial keeps the main example focused on ETH transfers.

4. Confirming and revoking

Each owner can confirm a transaction once. The contract stores confirmations in a nested mapping and increments a counter in the transaction struct. If an owner changes their mind before execution, they can revoke their confirmation.

This explicit revocation flow is useful in real teams where approvals may depend on changing operational context.

5. Executing the transaction

Execution is allowed only when the confirmation count reaches the threshold. The contract marks the transaction as executed before making the external call. This ordering is important because it reduces the risk of reentrancy-based double execution.

The wallet uses a low-level call:

(bool success, ) = txn.to.call{value: txn.value}(txn.data);

That is the standard way to send ETH and optionally invoke a function on another contract. Because low-level calls can fail, the code checks success and reverts if needed.

Security considerations

A multi-signature wallet handles valuable assets, so small mistakes matter.

Reentrancy safety

The contract sets executed = true before the external call. That is a classic checks-effects-interactions pattern and helps prevent reentrant re-execution of the same transaction.

If you extend the wallet with more complex logic, consider adding a dedicated reentrancy guard as well.

Owner uniqueness

Duplicate owners would distort the threshold logic. For example, if the same address appeared twice in the owner list, the wallet could appear to have more signers than it really does. The constructor prevents that.

Threshold validation

A threshold larger than the number of owners would make execution impossible. A threshold of zero would make the wallet meaningless. Both are rejected.

Event logging

Events are not just for convenience. In operational settings, they provide an auditable trail for deposits, approvals, revocations, and executions. This is especially important for treasury transparency.

Avoiding arbitrary execution surprises

Because the wallet supports arbitrary calldata, owners should review transaction payloads carefully before confirming. In production, teams often pair the wallet with off-chain transaction previews and human-readable decoding.

Example workflow

Suppose a wallet has three owners: Alice, Bob, and Carol, with a 2-of-3 threshold.

  1. Alice submits a transaction to send 1 ETH to a vendor.
  2. Alice confirms it.
  3. Bob confirms it.
  4. Any owner can execute it once the threshold is met.

If Bob later notices a mistake before execution, he can revoke his confirmation. The transaction then falls below the threshold and cannot be executed until another approval is added.

Practical extensions

The contract above is intentionally compact. In real projects, you may want to add the following features.

ExtensionWhy it helps
Owner replacementSupports key rotation without redeploying
ERC-20 transfersLets the wallet manage token treasuries
Arbitrary contract callsEnables protocol administration
Transaction expirationPrevents stale approvals from being used later
Off-chain signaturesReduces gas costs with typed approvals
Pagination helpersImproves UX for large transaction histories

Owner replacement

A production wallet often needs a way to add or remove owners. This is especially important if a signer loses access or leaves the organization. If you implement this, make sure owner changes themselves require multi-sig approval.

Token support

To transfer ERC-20 tokens, the transaction payload can call token.transfer(to, amount) on the token contract. The same confirmation logic still applies, but the wallet must hold the tokens.

Off-chain approvals

For gas efficiency, many modern multi-sig systems collect signatures off-chain and verify them on-chain only when executing. That approach is more advanced, but the approval model remains the same.

Testing recommendations

A wallet like this should be tested thoroughly. Focus on behavior, not just compilation.

Recommended test cases:

  • deployment fails with zero owners
  • deployment fails when threshold is invalid
  • non-owners cannot submit or confirm
  • duplicate confirmations are rejected
  • revocation decreases the confirmation count
  • execution fails below threshold
  • execution succeeds at threshold
  • executed transactions cannot be re-executed
  • deposits update the wallet balance correctly

If you use a framework such as Foundry or Hardhat, write tests that simulate multiple signers and verify event emission as well as balance changes.

Best practices for production use

A minimal multi-sig wallet is a good learning project, but production deployments should go further:

  • use audited libraries where possible
  • add explicit owner management functions
  • support typed data signatures for better UX
  • consider timelocks for sensitive actions
  • document operational procedures for signers
  • monitor events with an indexer or alerting system

Also remember that smart contract security is not only about code correctness. It includes key management, signer coordination, and transaction review processes.

Conclusion

A multi-signature wallet is one of the most practical Solidity patterns for shared control of funds and administrative actions. By requiring multiple approvals, it reduces the risk of single-key compromise and creates a transparent approval workflow on-chain.

The contract in this tutorial provides a strong foundation: owner validation, transaction submission, confirmation tracking, revocation, and safe execution. From here, you can extend it with token support, owner rotation, and off-chain signature verification to match real-world treasury requirements.

Learn more with useful resources