Skip to content
On this page

L3-8: Multisig Extension

1. Problem

Controlling large amounts of funds with a single private key is extremely dangerous -- theft, loss, or malicious action by the key holder can result in irreversible loss of funds. Multisig wallets mitigate this risk by requiring M-of-N signer approval to execute transactions. However, multisig wallets themselves face design challenges: how are signatures collected and verified? How do you prevent the same signature from being replayed across different transactions? How can the multisig wallet itself be upgraded (e.g., adding or removing signers)?

Gnosis Safe (now Safe) is the production-grade solution, securing tens of billions of dollars in assets. This challenge builds a simplified version -- using eth_sign-compatible ECDSA signature verification directly, without relying on the full EIP-712 structure. Understanding multisig implementation is foundational for building team treasuries, DAO financial management, and institutional-grade custody tools.

2. Why

Multisig is the infrastructure for fund management in Web3 organizations. Nearly all DAO treasuries use multisig (typically through Safe), and core team fund management likewise depends on multisig. Understanding its internal mechanics -- from signer set management, to transaction hash construction, to signature ordering for replay protection -- is not only a contract development skill but also critical knowledge for understanding collaborative security management.

Compared to single-signature setups, multisig provides key security enhancements in the following scenarios: (1) preventing a single point of malice -- collusion is required; (2) preventing private key loss -- remaining signers can still operate; (3) hierarchical approval -- different thresholds correspond to different operation sensitivities. This challenge also demonstrates advanced application of ECDSA signature recovery (ecrecover) -- the technique of sorting signer addresses to prevent duplicate signatures.

3. Solution

MultiSigWallet.sol implements an N-of-M multisig wallet with the following core features:

Constructor: Accepts an initial list of owners and a threshold. Validation: threshold > 0 and <= owner count, each address non-zero and non-duplicate.

Transaction Execution (execTransaction): This is the core function of the multisig. The flow is as follows:

  1. Build transaction hash: keccak256(abi.encode(to, value, keccak256(data), nonce)) -- uses eth_sign compatible format (no EIP-712 prefix)
  2. Verify signatures: _verifySignatures() parses the concatenated signature bytes (each signature is 65 bytes = r(32) + s(32) + v(1)), recovering signer addresses one by one
  3. Signature ordering validation: each recovered signer address must be strictly greater than the previous one (uint160(signer) > uint160(lastSigner)), preventing duplicate signatures
  4. Execution: sends the transaction via call, increments nonce on success

Owner Management: addOwner() and removeOwner() can only be executed through the multisig itself (the onlyMultiSig modifier requires msg.sender == address(this)). This means that adding or removing signers is itself an operation requiring multisig approval -- self-management and self-governance.

Signature Verification (_verifySignatures): Extracts r, s, v from the 65-byte signature. Normalizes the v value: if v < 27, adds 27 (compatible with Ethereum's v-value encoding). Uses ecrecover(hash, v, r, s) to recover the signer.

Reference source file: src/level3/MultiSigWallet.sol

4. Pitfalls Encountered

  • Edge cases in signature ordering: If two signer addresses have very close uint160 values, is the comparison logic still valid?
  • Invalid signature risk with ecrecover: ecrecover returns address(0) on invalid signatures instead of reverting -- if the contract does not check signer != address(0), it may accept a forged zero-address signature
  • Replay protection via nonce management: The nonce increments with each successful execution. If signers have signed transactions for both the current nonce and future nonces, is the execution order deterministic?
  • Signature format incompatibility with external tools: eth_sign (vm.sign) signs the bytes32 hash directly, while MetaMask's personal_sign adds the \x19Ethereum Signed Message:\n32 prefix -- the two produce different recovered signers
  • No timelock or expiration mechanism: Transactions signed by signers are theoretically valid forever (until the nonce is consumed); if a signature is leaked but not yet submitted, there is a risk

5. Why the Pitfalls Occur

The quirky behavior of ecrecover is one of the most classic pitfalls in Solidity. The EVM precompile ecrecover returns address(0) in the following cases: (1) the signature format is correct but the signer is not the expected address; (2) the v value is not 27 or 28; (3) r or s is outside the valid range of the secp256k1 curve. If the contract code does not check recovered != address(0), an attacker could pass a carefully crafted signature that causes ecrecover to return the zero address -- and the zero address might coincidentally be a legitimate signer in the contract (though unlikely, since the zero address is usually excluded).

In MultiSigWallet.sol, _recoverSigner() internally performs require(v == 27 || v == 28, "Invalid v") but does not check whether the recovered address is zero. The isOwner[signer] check in _verifySignatures() would catch the zero address (since the constructor does not allow the zero address as an owner), so this specific vulnerability is indirectly mitigated, but it depends on the integrity of the isOwner mapping.

The signature format incompatibility problem stems from the fragmentation of the Ethereum signing ecosystem. eth_sign signs the bytes32 hash directly (Foundry's vm.sign uses this format), while personal_sign adds a prefix, and EIP-712 adds a domain separator. The hash construction method used in the contract must exactly match the signing method used by the frontend. In MultiSigWallet.sol, getTransactionHash() returns keccak256(abi.encode(to, value, keccak256(data), _nonce)), and the frontend must compute the hash using the same encoding method and sign with Foundry's vm.sign.

6. How to Resolve the Pitfalls

Safe ecrecover Wrapper:

solidity
function _recoverSigner(bytes32 hash, bytes memory sig) internal pure returns (address) {
    require(sig.length == 65, "Invalid sig len");
    // ... extract r, s, v ...
    address signer = ecrecover(hash, v, r, s);
    require(signer != address(0), "Invalid signature");
    return signer;
}

Signer Ordering for Replay Prevention: The current ordering design (strictly increasing by address) already effectively prevents duplicate signatures and modification of signer order. It is important to also sort signatures by signer address on the JavaScript side:

javascript
signatures.sort((a, b) => 
  BigInt(a.signer) < BigInt(b.signer) ? -1 : 1
);

Nonce Locking: Explicitly specify the current nonce when signing transactions. The frontend queries the latest nonce via multisig.nonce(). Once a transaction successfully executes, the nonce increments and all signatures targeting the old nonce become invalid.

Adding Expiration: Production implementations should include a validUntil timestamp in the transaction hash, causing signatures to expire after the specified time.

7. Technical Highlights

TechniqueDescription
N-of-M Thresholdthreshold signatures from owners are required for execution
Signature OrderingAscending order by uint160(signer), preventing duplicates
Signature FormatEach signature is 65 bytes: r(32) + s(32) + v(1)
ecrecoverEVM precompile that recovers the ECDSA signer address
Nonce Replay ProtectionIncrements on each successful execution, invalidating old signatures
onlyMultiSigAdministrative operations themselves require multisig approval
eth_sign CompatibleSigns bytes32 hash directly, matching Foundry vm.sign
swap-and-popClassic pattern for removing members from the owners array

Built with AiAda