IAMUVIN

Web3 Development

How to Build a Multi-Sig Wallet Smart Contract

Uvin Vindula·May 13, 2024·11 min read
Share

TL;DR

A multi-sig wallet requires M-of-N owners to approve a transaction before it executes onchain. It is the single most important security primitive for any production smart contract that has admin functions, treasury access, or upgrade authority. I recommend Gnosis Safe for most teams because it is battle-tested, audited, and has a great UI. But I have built custom multi-sig contracts for clients who needed specific approval flows that Safe could not accommodate — tiered thresholds based on transaction value, time-locked execution windows, role-based signer groups. This article walks through a complete multi-sig wallet contract in Solidity 0.8.24+, covering transaction submission, the approval flow, execution logic, owner management, and comprehensive Foundry tests. If you are deploying any contract with privileged functions to mainnet without a multi-sig controlling those functions, you are one compromised private key away from losing everything.


Why Multi-Sig

I will be blunt. If you are using a single EOA (externally owned account) to control admin functions on a production smart contract, you are running a ticking time bomb. It does not matter how careful you are with your private key. It does not matter if you use a hardware wallet. A single point of failure is a single point of failure.

Here is what I have seen go wrong with single-key admin setups:

  1. Key compromise. A developer's laptop gets stolen. A seed phrase backup gets found. A phishing attack succeeds. One key, one catastrophe. The attacker drains the treasury, upgrades the contract to a malicious implementation, or mints tokens to themselves.
  2. Insider risk. A disgruntled team member with sole admin access decides to rug. No checks, no balances, no recourse.
  3. Bus factor. The one person with the admin key gets hit by a bus — metaphorically or literally. The protocol is frozen. No upgrades, no parameter changes, no emergency responses.
  4. Regulatory scrutiny. Regulators are increasingly looking at governance structures. A single-key admin is a red flag in any serious audit or compliance review.

Multi-sig solves all of these by distributing trust. A 3-of-5 multi-sig means three out of five designated owners must approve a transaction before it executes. No single person can act unilaterally. No single compromised key can drain the treasury. If one signer disappears, the remaining four can still operate and eventually rotate the missing signer out.

Every production contract I deploy for clients has its admin functions controlled by a multi-sig. Every single one. It is non-negotiable. The cost of setting up a multi-sig is trivial compared to the cost of a single exploit through a compromised admin key.

Gnosis Safe vs Custom

Before I show you how to build a multi-sig from scratch, I need to be honest about when you should not build one from scratch.

Use Gnosis Safe (now Safe) when:

  • You need a standard M-of-N approval flow
  • You want a polished UI for non-technical signers
  • You need module support for spending limits, recurring transactions, or DeFi integrations
  • You are deploying to any chain Safe supports (Ethereum, Arbitrum, Optimism, Base, Polygon, and dozens more)
  • You do not have the budget for a dedicated audit of your custom multi-sig
  • Your team wants to move fast and not maintain wallet infrastructure

Safe has been audited extensively, handles billions of dollars in TVL, and has a battle-tested track record. For 90% of teams, it is the right choice. I recommend it to almost every client I work with.

Build a custom multi-sig when:

  • You need tiered approval thresholds (for example, 2-of-5 for transactions under 10 ETH, 4-of-5 for anything above)
  • You need role-based signing groups where different sets of signers approve different categories of transactions
  • You need custom time-lock logic that is tightly coupled with the approval flow
  • You need the multi-sig to be embedded directly into your protocol contract rather than existing as a separate wallet
  • You need onchain governance hooks that trigger automatically after execution
  • You are building an educational project and want to understand the mechanics deeply

I have built custom multi-sig contracts for three clients in the past two years. One needed value-based tiered thresholds for an institutional custody product. Another needed a multi-sig where certain signers could only approve specific function selectors. The third needed a multi-sig with a built-in cool-down period between approval and execution for compliance reasons. None of these were possible with Safe out of the box.

Let me show you how to build one.

Architecture

A multi-sig wallet has four core responsibilities:

  1. Receive and hold funds. The contract acts as a treasury that can receive ETH and tokens.
  2. Submit transactions. Any owner can propose a transaction — a target address, a value, and calldata.
  3. Approve transactions. Owners review and approve pending transactions. Each transaction tracks which owners have approved it.
  4. Execute transactions. Once a transaction has enough approvals (meets the threshold), any owner can trigger execution. The contract makes the external call.

Here is the data model:

┌─────────────────────────────────────────────────┐
│                  MultiSigWallet                  │
├─────────────────────────────────────────────────┤
│  owners: address[]                               │
│  isOwner: mapping(address => bool)               │
│  threshold: uint256                              │
│  transactionCount: uint256                       │
│                                                  │
│  transactions: mapping(uint256 => Transaction)   │
│  approvals: mapping(uint256 => mapping(         │
│               address => bool))                  │
├─────────────────────────────────────────────────┤
│  struct Transaction {                            │
│    address to;                                   │
│    uint256 value;                                │
│    bytes data;                                   │
│    bool executed;                                │
│    uint256 approvalCount;                        │
│  }                                               │
└─────────────────────────────────────────────────┘

Flow:
  Owner A ──submit()──> Transaction #7 (pending)
  Owner B ──approve(7)──> approvalCount: 2
  Owner C ──approve(7)──> approvalCount: 3 (meets threshold)
  Owner A ──execute(7)──> external call fires

The design follows the Checks-Effects-Interactions pattern throughout. State changes happen before external calls. Every function that modifies state validates permissions first. Events are emitted for every significant action so indexers and frontends can track activity.

The Multi-Sig Contract -- Solidity

Here is the complete contract. I will walk through each section after the full listing.

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

/// @title MultiSigWallet
/// @author Uvin Vindula (@IAMUVIN)
/// @notice M-of-N multi-signature wallet for secure treasury management
/// @dev Follows Checks-Effects-Interactions. No delegatecall. No proxy.
contract MultiSigWallet {
    // ─── Events ──────────────────────────────────────────────
    event Deposit(address indexed sender, uint256 amount);
    event TransactionSubmitted(
        uint256 indexed txId,
        address indexed to,
        uint256 value,
        bytes data
    );
    event TransactionApproved(
        uint256 indexed txId,
        address indexed owner
    );
    event ApprovalRevoked(
        uint256 indexed txId,
        address indexed owner
    );
    event TransactionExecuted(uint256 indexed txId);
    event OwnerAdded(address indexed owner);
    event OwnerRemoved(address indexed owner);
    event ThresholdChanged(uint256 newThreshold);

    // ─── Errors ──────────────────────────────────────────────
    error NotOwner();
    error TxDoesNotExist();
    error TxAlreadyExecuted();
    error TxAlreadyApproved();
    error TxNotApproved();
    error ThresholdNotMet();
    error ExecutionFailed();
    error InvalidOwner();
    error DuplicateOwner();
    error InvalidThreshold();
    error OwnerRequired();
    error NotWallet();

    // ─── State ───────────────────────────────────────────────
    struct Transaction {
        address to;
        uint256 value;
        bytes data;
        bool executed;
        uint256 approvalCount;
    }

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

    mapping(uint256 => Transaction) public transactions;
    mapping(uint256 => mapping(address => bool)) public approvals;

    // ─── Modifiers ───────────────────────────────────────────
    modifier onlyOwner() {
        if (!isOwner[msg.sender]) revert NotOwner();
        _;
    }

    modifier onlyWallet() {
        if (msg.sender != address(this)) revert NotWallet();
        _;
    }

    modifier txExists(uint256 _txId) {
        if (_txId >= transactionCount) revert TxDoesNotExist();
        _;
    }

    modifier notExecuted(uint256 _txId) {
        if (transactions[_txId].executed) revert TxAlreadyExecuted();
        _;
    }

    // ─── Constructor ─────────────────────────────────────────
    /// @param _owners Initial set of wallet owners
    /// @param _threshold Number of approvals required to execute
    constructor(address[] memory _owners, uint256 _threshold) {
        uint256 ownerCount = _owners.length;
        if (ownerCount == 0) revert OwnerRequired();
        if (
            _threshold == 0 ||
            _threshold > ownerCount
        ) {
            revert InvalidThreshold();
        }

        for (uint256 i; i < ownerCount; ++i) {
            address owner = _owners[i];
            if (owner == address(0)) revert InvalidOwner();
            if (isOwner[owner]) revert DuplicateOwner();

            isOwner[owner] = true;
            owners.push(owner);
        }

        threshold = _threshold;
    }

    // ─── Receive ─────────────────────────────────────────────
    receive() external payable {
        emit Deposit(msg.sender, msg.value);
    }

    // ─── Submit ──────────────────────────────────────────────
    /// @notice Propose a new transaction for approval
    /// @param _to Target address for the transaction
    /// @param _value ETH value to send
    /// @param _data Calldata for the transaction
    /// @return txId The ID of the newly created transaction
    function submit(
        address _to,
        uint256 _value,
        bytes calldata _data
    ) external onlyOwner returns (uint256 txId) {
        txId = transactionCount;

        transactions[txId] = Transaction({
            to: _to,
            value: _value,
            data: _data,
            executed: false,
            approvalCount: 0
        });

        transactionCount = txId + 1;

        emit TransactionSubmitted(txId, _to, _value, _data);
    }

    // ─── Approve ─────────────────────────────────────────────
    /// @notice Approve a pending transaction
    /// @param _txId The transaction ID to approve
    function approve(uint256 _txId)
        external
        onlyOwner
        txExists(_txId)
        notExecuted(_txId)
    {
        if (approvals[_txId][msg.sender]) revert TxAlreadyApproved();

        approvals[_txId][msg.sender] = true;
        transactions[_txId].approvalCount += 1;

        emit TransactionApproved(_txId, msg.sender);
    }

    // ─── Revoke ──────────────────────────────────────────────
    /// @notice Revoke a previous approval
    /// @param _txId The transaction ID to revoke approval from
    function revoke(uint256 _txId)
        external
        onlyOwner
        txExists(_txId)
        notExecuted(_txId)
    {
        if (!approvals[_txId][msg.sender]) revert TxNotApproved();

        approvals[_txId][msg.sender] = false;
        transactions[_txId].approvalCount -= 1;

        emit ApprovalRevoked(_txId, msg.sender);
    }

    // ─── Execute ─────────────────────────────────────────────
    /// @notice Execute a transaction that has met the approval threshold
    /// @param _txId The transaction ID to execute
    function execute(uint256 _txId)
        external
        onlyOwner
        txExists(_txId)
        notExecuted(_txId)
    {
        Transaction storage txn = transactions[_txId];

        if (txn.approvalCount < threshold) revert ThresholdNotMet();

        txn.executed = true;

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

        emit TransactionExecuted(_txId);
    }

    // ─── Owner Management (self-governed) ────────────────────
    /// @notice Add a new owner. Must be called through the multi-sig itself.
    /// @param _owner The address to add as an owner
    function addOwner(address _owner) external onlyWallet {
        if (_owner == address(0)) revert InvalidOwner();
        if (isOwner[_owner]) revert DuplicateOwner();

        isOwner[_owner] = true;
        owners.push(_owner);

        emit OwnerAdded(_owner);
    }

    /// @notice Remove an existing owner. Must be called through the multi-sig.
    /// @param _owner The address to remove
    function removeOwner(address _owner) external onlyWallet {
        if (!isOwner[_owner]) revert InvalidOwner();

        isOwner[_owner] = false;

        uint256 ownerCount = owners.length;
        for (uint256 i; i < ownerCount; ++i) {
            if (owners[i] == _owner) {
                owners[i] = owners[ownerCount - 1];
                owners.pop();
                break;
            }
        }

        if (owners.length == 0) revert OwnerRequired();
        if (threshold > owners.length) {
            threshold = owners.length;
            emit ThresholdChanged(threshold);
        }

        emit OwnerRemoved(_owner);
    }

    /// @notice Change the approval threshold. Must be called through the multi-sig.
    /// @param _threshold The new threshold value
    function changeThreshold(uint256 _threshold) external onlyWallet {
        if (
            _threshold == 0 ||
            _threshold > owners.length
        ) {
            revert InvalidThreshold();
        }

        threshold = _threshold;
        emit ThresholdChanged(_threshold);
    }

    // ─── View Functions ──────────────────────────────────────
    /// @notice Get the list of all owners
    function getOwners() external view returns (address[] memory) {
        return owners;
    }

    /// @notice Get full transaction details
    function getTransaction(uint256 _txId)
        external
        view
        txExists(_txId)
        returns (
            address to,
            uint256 value,
            bytes memory data,
            bool executed,
            uint256 approvalCount
        )
    {
        Transaction storage txn = transactions[_txId];
        return (txn.to, txn.value, txn.data, txn.executed, txn.approvalCount);
    }
}

Let me break down the critical design decisions.

Submitting Transactions

The submit function is deliberately simple. Any owner can propose any transaction — a target address, an ETH value, and arbitrary calldata. The function does not validate what the transaction does. That is intentional. The security comes from the approval process, not from restricting what can be proposed.

solidity
function submit(
    address _to,
    uint256 _value,
    bytes calldata _data
) external onlyOwner returns (uint256 txId) {
    txId = transactionCount;
    transactions[txId] = Transaction({
        to: _to,
        value: _value,
        data: _data,
        executed: false,
        approvalCount: 0
    });
    transactionCount = txId + 1;
    emit TransactionSubmitted(txId, _to, _value, _data);
}

Key decisions:

  • `bytes calldata _data` — Using calldata instead of memory saves gas because we do not need to copy the data into memory during submission. The data gets stored in the transaction struct for later execution.
  • Sequential IDs — Transaction IDs are simple incrementing integers. No hashing, no nonces. This makes it trivial for frontends to enumerate all transactions and for signers to reference them unambiguously.
  • No auto-approval — The submitter does not automatically approve their own transaction. This is a conscious choice. Some multi-sig implementations auto-approve on submission, but I prefer explicitness. Every approval is a deliberate action. If the submitter wants to approve, they call approve separately.

The TransactionSubmitted event carries all the transaction data, which means indexers and frontends can reconstruct the full transaction without making additional RPC calls.

Approval Flow

The approval mechanism is where multi-sig security actually lives. Each owner can approve or revoke their approval for any pending transaction. The contract tracks approvals in a nested mapping: mapping(uint256 => mapping(address => bool)).

solidity
function approve(uint256 _txId)
    external
    onlyOwner
    txExists(_txId)
    notExecuted(_txId)
{
    if (approvals[_txId][msg.sender]) revert TxAlreadyApproved();
    approvals[_txId][msg.sender] = true;
    transactions[_txId].approvalCount += 1;
    emit TransactionApproved(_txId, msg.sender);
}

The approvalCount is stored directly in the transaction struct rather than being computed on the fly. This costs a bit more gas on approval and revocation (one extra SSTORE), but it saves significant gas during execution because we do not need to loop through all owners to count approvals.

The revoke function mirrors approve:

solidity
function revoke(uint256 _txId)
    external
    onlyOwner
    txExists(_txId)
    notExecuted(_txId)
{
    if (!approvals[_txId][msg.sender]) revert TxNotApproved();
    approvals[_txId][msg.sender] = false;
    transactions[_txId].approvalCount -= 1;
    emit ApprovalRevoked(_txId, msg.sender);
}

Revocation is critical for security. If an owner approves a transaction and then realizes the calldata is malicious (maybe another owner submitted a transaction that looks like a token transfer but actually calls a different function), they can revoke before execution. Without revocation, a compromised proposer could submit a malicious transaction and rely on other owners approving based on a description rather than verifying the actual calldata.

This is also why I always tell clients: verify the calldata, not just the description. Decode the function selector and parameters before approving. Tools like Safe's transaction builder show you exactly what a transaction will do. If you are building a custom frontend for your multi-sig, include calldata decoding in the approval UI.

Execution Logic

Execution is where the Checks-Effects-Interactions pattern matters most. The function validates permissions and threshold, marks the transaction as executed, and only then makes the external call.

solidity
function execute(uint256 _txId)
    external
    onlyOwner
    txExists(_txId)
    notExecuted(_txId)
{
    Transaction storage txn = transactions[_txId];

    if (txn.approvalCount < threshold) revert ThresholdNotMet();

    // Effects BEFORE interactions
    txn.executed = true;

    // Interaction — external call
    (bool success, ) = txn.to.call{value: txn.value}(txn.data);
    if (!success) revert ExecutionFailed();

    emit TransactionExecuted(_txId);
}

The order here is non-negotiable:

  1. Checks — Is the caller an owner? Does the transaction exist? Is it not already executed? Does it have enough approvals?
  2. Effects — Mark executed = true before the external call. This prevents reentrancy. Even if the target contract calls back into our multi-sig, the transaction is already marked as executed and cannot be replayed.
  3. Interactions — Make the external call. If it fails, revert everything.

One design decision worth discussing: I revert on failed execution rather than storing a failure flag. Some multi-sig implementations allow failed executions and let owners retry. I prefer the revert approach because a failed transaction means something unexpected happened, and the owners should investigate before trying again. If the transaction was correct, it should succeed. If it failed, the owners should submit a new transaction with corrected parameters after understanding why.

The call is used with both value and data, making this multi-sig capable of:

  • Sending plain ETH transfers (data is empty, value is non-zero)
  • Calling contract functions (data contains the encoded function call, value can be zero or non-zero)
  • Calling payable functions on other contracts (both data and value are set)

Owner Management

Owner management functions use the onlyWallet modifier, meaning they can only be called by the multi-sig itself. This is the self-governance pattern — adding, removing, or changing the threshold requires going through the full submit-approve-execute flow.

solidity
modifier onlyWallet() {
    if (msg.sender != address(this)) revert NotWallet();
    _;
}

To add a new owner, an existing owner submits a transaction where the target is the multi-sig contract itself, and the calldata encodes the addOwner function:

solidity
// Submitting an owner addition through the multi-sig
bytes memory data = abi.encodeWithSelector(
    MultiSigWallet.addOwner.selector,
    newOwnerAddress
);
wallet.submit(address(wallet), 0, data);

The removeOwner function includes two safety checks that prevent the multi-sig from bricking itself:

  1. Owner count guard — You cannot remove the last owner. An ownerless multi-sig is a dead multi-sig.
  2. Threshold auto-adjustment — If removing an owner would make the current threshold impossible to meet (for example, removing the third owner from a 3-of-3), the threshold automatically drops to match the new owner count.
solidity
if (owners.length == 0) revert OwnerRequired();
if (threshold > owners.length) {
    threshold = owners.length;
    emit ThresholdChanged(threshold);
}

The array manipulation for removal uses the swap-and-pop pattern instead of shifting elements. This is O(1) instead of O(n), which matters when the owner list is large. The trade-off is that owner ordering is not preserved, but ordering does not matter for a multi-sig — only membership does.

Testing Multi-Sig Scenarios

Testing multi-sig contracts requires covering the happy paths AND the adversarial paths. Here are the Foundry test scenarios I write for every multi-sig deployment:

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

import {Test, console2} from "forge-std/Test.sol";
import {MultiSigWallet} from "../src/MultiSigWallet.sol";

contract MultiSigWalletTest is Test {
    MultiSigWallet wallet;

    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address carol = makeAddr("carol");
    address dave = makeAddr("dave");
    address eve = makeAddr("eve");

    address[] owners;

    function setUp() public {
        owners.push(alice);
        owners.push(bob);
        owners.push(carol);
        uint256 threshold = 2;

        wallet = new MultiSigWallet(owners, threshold);
        vm.deal(address(wallet), 100 ether);
    }

    // ─── Deployment ──────────────────────────────────────
    function test_DeploymentSetsOwners() public view {
        address[] memory result = wallet.getOwners();
        assertEq(result.length, 3);
        assertTrue(wallet.isOwner(alice));
        assertTrue(wallet.isOwner(bob));
        assertTrue(wallet.isOwner(carol));
    }

    function test_DeploymentSetsThreshold() public view {
        assertEq(wallet.threshold(), 2);
    }

    function test_RevertDeployWithZeroOwners() public {
        address[] memory empty = new address[](0);
        vm.expectRevert(MultiSigWallet.OwnerRequired.selector);
        new MultiSigWallet(empty, 1);
    }

    function test_RevertDeployWithZeroThreshold() public {
        vm.expectRevert(MultiSigWallet.InvalidThreshold.selector);
        new MultiSigWallet(owners, 0);
    }

    // ─── Submit ──────────────────────────────────────────
    function test_OwnerCanSubmit() public {
        vm.prank(alice);
        uint256 txId = wallet.submit(dave, 1 ether, "");
        assertEq(txId, 0);
        assertEq(wallet.transactionCount(), 1);
    }

    function test_NonOwnerCannotSubmit() public {
        vm.prank(dave);
        vm.expectRevert(MultiSigWallet.NotOwner.selector);
        wallet.submit(dave, 1 ether, "");
    }

    // ─── Approve + Execute ───────────────────────────────
    function test_FullApproveAndExecuteFlow() public {
        vm.prank(alice);
        uint256 txId = wallet.submit(dave, 1 ether, "");

        vm.prank(alice);
        wallet.approve(txId);

        vm.prank(bob);
        wallet.approve(txId);

        uint256 balanceBefore = dave.balance;

        vm.prank(alice);
        wallet.execute(txId);

        assertEq(dave.balance, balanceBefore + 1 ether);
    }

    function test_CannotExecuteBelowThreshold() public {
        vm.prank(alice);
        uint256 txId = wallet.submit(dave, 1 ether, "");

        vm.prank(alice);
        wallet.approve(txId);

        vm.prank(alice);
        vm.expectRevert(MultiSigWallet.ThresholdNotMet.selector);
        wallet.execute(txId);
    }

    function test_CannotExecuteTwice() public {
        vm.prank(alice);
        uint256 txId = wallet.submit(dave, 1 ether, "");

        vm.prank(alice);
        wallet.approve(txId);
        vm.prank(bob);
        wallet.approve(txId);

        vm.prank(alice);
        wallet.execute(txId);

        vm.prank(alice);
        vm.expectRevert(MultiSigWallet.TxAlreadyExecuted.selector);
        wallet.execute(txId);
    }

    // ─── Revoke ──────────────────────────────────────────
    function test_OwnerCanRevoke() public {
        vm.prank(alice);
        uint256 txId = wallet.submit(dave, 1 ether, "");

        vm.prank(alice);
        wallet.approve(txId);

        vm.prank(alice);
        wallet.revoke(txId);

        (, , , , uint256 count) = wallet.getTransaction(txId);
        assertEq(count, 0);
    }

    // ─── Owner Management ────────────────────────────────
    function test_AddOwnerThroughMultiSig() public {
        bytes memory data = abi.encodeWithSelector(
            MultiSigWallet.addOwner.selector,
            dave
        );

        vm.prank(alice);
        uint256 txId = wallet.submit(address(wallet), 0, data);

        vm.prank(alice);
        wallet.approve(txId);
        vm.prank(bob);
        wallet.approve(txId);

        vm.prank(alice);
        wallet.execute(txId);

        assertTrue(wallet.isOwner(dave));
    }

    function test_CannotAddOwnerDirectly() public {
        vm.prank(alice);
        vm.expectRevert(MultiSigWallet.NotWallet.selector);
        wallet.addOwner(dave);
    }

    // ─── Fuzz Tests ──────────────────────────────────────
    function testFuzz_ThresholdBounds(
        uint256 ownerCount,
        uint256 thresh
    ) public {
        ownerCount = bound(ownerCount, 1, 20);
        thresh = bound(thresh, 1, ownerCount);

        address[] memory fuzzOwners = new address[](ownerCount);
        for (uint256 i; i < ownerCount; ++i) {
            fuzzOwners[i] = address(uint160(i + 100));
        }

        MultiSigWallet w = new MultiSigWallet(fuzzOwners, thresh);
        assertEq(w.threshold(), thresh);
        assertEq(w.getOwners().length, ownerCount);
    }
}

The test scenarios I consider essential for any multi-sig:

  • Happy path — Submit, approve with enough signers, execute. Funds move correctly.
  • Threshold enforcement — Cannot execute with fewer approvals than the threshold.
  • Double execution prevention — An executed transaction cannot be replayed.
  • Revocation — Owners can change their mind before execution.
  • Self-governance — Owner additions and removals go through the multi-sig flow.
  • Direct call prevention — Management functions cannot be called by owners directly.
  • Fuzz testing — Random owner counts and thresholds to catch edge cases in constructor validation.
  • Reentrancy — Submit a transaction to a malicious contract that tries to re-enter the multi-sig during execution. The executed = true flag should prevent it.

I always run fuzz tests with at least 10,000 iterations. Edge cases in threshold math — like a 1-of-1 multi-sig or a 20-of-20 multi-sig — should all behave correctly.

When to Use Gnosis Safe Instead

After showing you how to build a multi-sig, let me reiterate when you should not build one.

Gnosis Safe wins on:

  • Audit coverage. Safe has undergone multiple professional audits from firms like G0 Group and Ackee Blockchain. Your custom contract has zero audits unless you pay for one (and you should — $30K to $100K depending on complexity).
  • Battle testing. Safe secures over $100 billion in assets across chains. Your custom contract secures whatever you put in it on day one.
  • UI/UX. The Safe web app and mobile app let non-technical signers review and approve transactions without touching a CLI or writing scripts.
  • Module ecosystem. Spending limits, recurring transactions, DeFi integrations, and social recovery — all available as plug-in modules.
  • Multi-chain deployment. Safe is deployed on every major EVM chain with the same addresses through CREATE2 deterministic deployment.
  • Ecosystem integration. Most DeFi protocols, DAOs, and governance frameworks have native Safe integration. Your custom contract needs custom integration work.

Your custom multi-sig wins on:

  • Specific business logic that does not fit the Safe module pattern
  • Gas efficiency for your specific use case (Safe is general-purpose and carries overhead for features you might not need)
  • Deep protocol integration where the multi-sig logic is inseparable from your protocol logic
  • Learning and understanding — building one teaches you more about smart contract security than reading ten articles about it

My recommendation: Start with Safe. Build custom only when Safe genuinely cannot do what you need. And if you do build custom, get it audited before putting real funds in it.

For every client project at iamuvin.com/services, I default to Safe for admin controls and only propose a custom multi-sig when the requirements genuinely demand it. Security is not the place to be creative for the sake of creativity.

Key Takeaways

  1. Multi-sig is mandatory for production admin functions. A single EOA controlling privileged operations is an unacceptable risk. Period.
  2. Gnosis Safe covers 90% of use cases. Use it unless you have a specific, documented reason why it cannot work for your project.
  3. Checks-Effects-Interactions is non-negotiable. Mark executed = true before making external calls. This is the line between a secure contract and a reentrancy exploit.
  4. Self-governance through `onlyWallet`. Owner management functions should only be callable through the multi-sig itself, never directly by individual owners.
  5. Test adversarial scenarios. Happy path testing is necessary but not sufficient. Test double-execution, revocation, threshold edge cases, and reentrancy.
  6. Verify calldata before approving. The biggest risk in a multi-sig is not the contract code — it is an owner blindly approving a malicious transaction because they trusted a description instead of decoding the calldata.
  7. Get custom multi-sig contracts audited. If you are building your own and putting real funds in it, a professional audit is not optional. The cost of an audit is a fraction of the cost of an exploit.

If you are building a protocol and need help setting up secure admin controls — whether that is configuring a Gnosis Safe with the right modules or building a custom multi-sig for specialized requirements — reach out through my services page. Multi-sig setup is one of those things where getting it right the first time saves you from a very expensive lesson later.


*Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK, building production-grade smart contracts, DeFi protocols, and full-stack decentralized applications. Follow his work at iamuvin.com or connect on X @IAMUVIN.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.