Building a Secure Multi-Signature Wallet: Minimal Code Implementation Inspired by Gnosis Safe

·

What Is a Multi-Signature Wallet?

A multi-signature (multisig) wallet enhances security by requiring multiple authorized signatures to execute transactions. The core principle: funds can only be moved when a predefined threshold of signers (e.g., 2 out of 3) approves an action. This reduces single-point failure risks common in traditional wallets.

Example:

Key Features of Multisig Wallets

Gnosis Safe: A Leading Multisig Solution

Gnosis Safe is a popular Ethereum-based multisig wallet managing over $1B in assets. Its decentralized, audited smart contracts offer robust security for collective fund management.

How Multisig Wallets Work

Core Components

  1. Smart Contract Storage:

    • An array of owner addresses (address[] owners).
    • A threshold value (uint8 threshold).
    uint8 public constant threshold = 2;  
    address[MAX_SIGNEE] public owners;  
  2. Execution Flow:

    • Off-Chain Signing: Collect signatures exceeding the threshold.
    • On-Chain Verification: Validate signatures against stored owners; execute the transaction if valid.

Step-by-Step Implementation

Off-Chain Signing

  1. Transaction Data: Define recipient (to), value (value), and calldata (data).

    address to = address(0x123);  
    uint256 value = 1 ether;  
    bytes data = abi.encodeWithSignature("setValue(uint256)", 123);  
  2. Message Hash: Hash the data twice (Ethereum standards):

    • First hash: keccak256(to, value, keccak256(data)).
    • Final hash: Prepend \x19Ethereum Signed Message:\n32.
    function buildMessageHash(address to, uint256 value, bytes memory data)  
        internal view returns (bytes32) {  
        bytes32 dataHash = keccak256(abi.encode(to, value, keccak256(data)));  
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));  
    }  
  3. Sign & Aggregate: Individual signers generate signatures (r,s,v); concatenate into one payload.

    function generateSignatures(bytes32 messageHash) public returns (bytes memory) {  
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey1, messageHash);  
        bytes memory sig1 = abi.encodePacked(r, s, v);  
        // Repeat for other signers  
        return abi.encodePacked(sig1, sig2); // 130 bytes for 2 sigs  
    }  
  4. Submit to Contract: Send original data + aggregated signatures to the multisig contract.

On-Chain Verification

  1. Recover Hash: Reconstruct the message hash identically to off-chain.
  2. Validate Signatures:

    • Split aggregated signatures into individual (r,s,v) components.
    • Recover signer addresses via ECDSA.recover().
    • Check recovered addresses exist in owners array.
    function checkSignatures(bytes32 messageHash, bytes memory signatures) internal view {  
        require(signatures.length >= threshold * 65, "Invalid length");  
        for (uint i = 0; i < threshold; i++) {  
            address recovered = ECDSA.recover(messageHash, signatures.slice(i*65, 65));  
            require(isOwner(recovered), "Invalid signer");  
        }  
    }  
  3. Execute Transaction: Use call() to forward funds/data if valid.

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

Minimal Gnosis Safe Clone: Full Code

👉 Explore the full repository for tested implementations.

On-Chain Contract (MultiSignature.sol)

contract MultiSignature {  
    uint8 public constant MAX_SIGNEE = 3;  
    uint8 public constant threshold = 2;  
    address[MAX_SIGNEE] public owners;  

    constructor(address[MAX_SIGNEE] memory _owners) {  
        for (uint i = 0; i < MAX_SIGNEE; i++) {  
            require(_owners[i] != address(0), "Invalid owner");  
            owners[i] = _owners[i];  
        }  
    }  

    function executeTransaction(address to, uint256 value, bytes memory data, bytes memory signatures) external {  
        bytes32 messageHash = recoverMessageHash(to, value, data);  
        checkSignatures(messageHash, signatures);  
        (bool success,) = to.call{value: value}(data);  
        require(success, "Execution failed");  
    }  

    function checkSignatures(bytes32 messageHash, bytes memory signatures) internal view {  
        require(signatures.length >= threshold * 65, "Bad signature length");  
        uint pos = 0;  
        for (uint i = 0; i < threshold; i++) {  
            address recovered = ECDSA.recover(messageHash, signatures.slice(pos, 65));  
            pos += 65;  
            require(isOwner(recovered), "Invalid signer");  
        }  
    }  

    function isOwner(address addr) internal view returns (bool) {  
        for (uint i = 0; i < MAX_SIGNEE; i++) {  
            if (owners[i] == addr) return true;  
        }  
        return false;  
    }  

    function recoverMessageHash(address to, uint256 value, bytes memory data) internal pure returns (bytes32) {  
        bytes32 dataHash = keccak256(abi.encode(to, value, keccak256(data)));  
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));  
    }  
}  

Testing Script

forge test --match-path test/multiSignatureTest.t.sol -vvvv  

FAQ

Q: Why use multisig wallets over traditional ones?
A: They mitigate single-point failures (e.g., leaked keys) and require collective approval for transactions.

Q: What’s the role of the threshold?
A: It sets the minimum number of signatures needed (e.g., 2/3 means two signers must approve).

Q: Can I dynamically add/remove signers?
A: This minimal version doesn’t support it, but full implementations (like Gnosis Safe) do.

Q: Is multisig slower due to multiple signatures?
A: Slightly—it requires coordination but adds critical security for high-value transactions.

👉 Learn more about advanced multisig features in our deep-dive guides.