
Building a Secure Multi-Signature Wallet in Solidity
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
| Component | Purpose |
|---|---|
owners | Stores the authorized signers |
isOwner | Fast lookup to validate caller permissions |
requiredConfirmations | Threshold needed to execute |
Transaction | Holds destination, value, calldata, and status |
confirmations | Tracks 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 addressvalue: ETH amount to senddata: 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.
- Alice submits a transaction to send 1 ETH to a vendor.
- Alice confirms it.
- Bob confirms it.
- 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.
| Extension | Why it helps |
|---|---|
| Owner replacement | Supports key rotation without redeploying |
| ERC-20 transfers | Lets the wallet manage token treasuries |
| Arbitrary contract calls | Enables protocol administration |
| Transaction expiration | Prevents stale approvals from being used later |
| Off-chain signatures | Reduces gas costs with typed approvals |
| Pagination helpers | Improves 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.
