How to Talk to a Multisig Wallet

A single private key is a single point of failure. One leaked seed phrase — the word list that backs up a wallet — one malicious dApp (a website that asks your wallet to approve something), one compromised signer, and the treasury is gone. A multisig splits authority across n keys and requires m of them to act, so no single compromise can move funds on its own.

What this post is about. Safe, the protocol behind the Gnosis Safe contracts, is the reference version of that idea. This post is about the client side: what a program that talks to a multisig wallet must get exactly right. You will learn how the protocol expects a transaction to be hashed, signed, sorted and submitted, and where a hand-written client most often breaks.

What you need to know first. A contract is code stored at an address on the blockchain. A transaction is a signed instruction that calls a contract, and it costs gas — the fee the network charges for computation and storage. A multisig is one contract that holds the funds and only acts when enough owners have approved the same instruction. A signature is that proof of approval: the owner’s private key produces one, and the contract recovers the signer’s address from it.

That idea is simple. Making a client that talks to one correctly is not — and that is what this repository is about. It contains no Solidity (the language Ethereum contracts are written in) at all: it is a ~570-line Node.js client for the canonical Gnosis Safe v1.3.0 contracts, plus compiled ABI artifacts — the machine-readable list of each contract’s functions and argument types — written to drive a Safe on a private testnet.

So the interesting question is not “how do I write a multisig”. It is: what does the protocol actually expect from a client, and what happens when you get it subtly wrong?

The situation

The first generation of multisig wallets was stateful: it kept a record of pending work on chain. Gnosis MultiSig v0.1.0 (2017) works like this:

  • An owner calls submitTransaction(dest, value, data). The contract stores a Transaction struct — a record grouping a few fields — in an array and increments a counter.
  • Other owners call confirmTransaction(txId), which flips a bit in an isConfirmed[txId][owner] mapping — a Solidity table of key to value.
  • Once required confirmations accumulate, anybody calls executeTransaction(txId), which sets isExecuted[txId] = true.

Every one of those steps is a transaction, and the contract carries a bytes32 txHash => Transaction map that grows without bound. The pending queue is the attack surface: stale proposals, griefing (making someone else’s life expensive at little cost to your own), storage costs, and a flag that has to be checked so a transaction cannot run twice.

Safe v1.3.0 replaces all of it with a different answer: there is no pending-transaction storage at all.

Why this design

The transaction is a signed value, not a stored record

In v1.3.0 the transaction is (to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce). The owners sign its EIP-712 hash off chain — EIP-712 is the standard for signing structured data, so a wallet can show the signer what they are approving. The signatures travel with the call. One transaction does propose, approve and execute atomically, meaning in one indivisible step.

The consequences are large: a Safe costs the same whether it has executed one transaction or a million. Nothing is ever “pending”, so nothing can be griefed by leaving it pending, and there is no state to expire.

The signed payload is the whole contract between the signers and the chain:

1
2
3
4
bytes32 safeTxHash = keccak256(abi.encode(
SAFE_TX_TYPEHASH, to, value, keccak256(data), operation,
safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, _nonce));
return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxHash);

(upstream safe-global/safe-smart-account v1.3.0, contracts/GnosisSafe.sol:365)

keccak256 is the hash function Ethereum uses everywhere. abi.encode is Solidity’s way of packing a list of values into one byte string, and SAFE_TX_TYPEHASH is a fixed constant that names the kind of message being signed. Every field of the transaction goes into the hash — recipient, value, the call data itself (hashed again), the operation type, the gas and refund terms, and the nonce — so a signature covers the whole deal, not a summary of it.

The domain separator binds the signature to this Safe on this chain:

1
2
3
function domainSeparator() public view returns (bytes32) {
return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, this));
}

chainId is the network’s number and this is the Safe’s own address. The bytes1(0x19), bytes1(0x01) prefix is a fixed marker from EIP-191 that says “this signature is meant for a contract to verify”. Without the separator, a signature gathered on one chain would be valid on another; the chain id is what rules that out.

Replay protection is built into the hash

The old design needed isExecuted[txId] to stop a transaction running twice. The new one needs nothing: the signed hash embeds the nonce — a counter of how many transactions this Safe has executed — and execTransaction increments the nonce before executing:

1
2
3
4
// Increase nonce and execute transaction.
nonce++;
txHash = keccak256(txHashData);
checkSignatures(txHash, txHashData, signatures);

(upstream GnosisSafe.sol:142)

Re-submitting an old signature set produces a different hash, because the nonce has moved on. The signatures no longer match, and the call reverts — fails and undoes itself. Replay is impossible by construction rather than guarded by a flag.

Owners live on chain, confirmations live off chain

The owner set lives on chain — as a singly-linked list, where each owner’s entry stores the address of the owner before it, with a constant SENTINEL_OWNERS = address(0x1) marking the start (contracts/base/OwnerManager.sol:13). The sentinel is a dummy value no real address can equal; it exists so the list always has a head.

That is why removeOwner needs the predecessor of the owner being removed, and why a client removing the first owner has to know about the sentinel. The confirmation set is off-chain signatures instead, with one optional on-chain exception (approvedHashes). Keeping them apart lets the same owners confirm different transactions at once, and makes confirmation free.

Guards run before and after

A guard is a contract the Safe consults on every owner-executed transaction. checkTransaction can revert — veto the transaction, even after a legitimate quorum (the required number of owners) signed it. checkAfterExecution runs afterwards, for bookkeeping you can only compute once you know the outcome: gas used, success flag, post-state invariants. A guard that ran only before could not see the result; one that ran only after could not stop anything.

Modules skip the threshold, on purpose

1
2
3
4
5
6
function execTransactionFromModule(address to, uint256 value, bytes memory data, Enum.Operation operation)
public virtual returns (bool success)
{
require(msg.sender != SENTINEL_MODULES && modules[msg.sender] != address(0), "GS104");
success = execute(to, value, data, operation, gasleft());
{ ... }

(upstream contracts/base/ModuleManager.sol:61)

A module is a policy engine — spending limits, allowlists, automation — that the owners have explicitly delegated to. It executes with zero owner confirmations. The require line means only an enabled module may call this (Safe’s error code GS104 means “module does not exist”), and gasleft() forwards the gas that is left. Two things protect this path: enableModule is authorized, so adding one required a quorum, and the module’s own code is the only thing that runs. Notably, the guard is not consulted on this path — the sharpest edge in the whole design, and worth stating plainly rather than discovering.

How it works

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
owner A ─┐
owner B ─┼─ sign EIP-712 hash (off chain, no gas)
owner C ─┘
│
▼
signatures sorted by signer address
│
▼
┌──────────────────────────────────────────┐
│ execTransaction(to, value, data, ..., │
│ signatures) │
│ │
│ nonce++ → hash → checkSignatures() │
│ → guard.checkTransaction() │
│ → execute() │
│ → guard.checkAfterExecution() │
└──────────────────────────────────────────┘
│
▼
ExecutionSuccess / ExecutionFailure

The three arrows at the top are off-chain signatures, which cost no gas; the one box is the single on-chain transaction, read top to bottom; the last arrow is the result flag the caller receives.

checkSignatures is where the client’s habits get punished. It picks up mid-loop, with v selecting which kind of signature it is looking at:

1
2
3
4
5
6
7
8
} else if (v == 1) {
currentOwner = address(uint160(uint256(r)));
require(msg.sender == currentOwner || approvedHashes[currentOwner][dataHash] != 0, "GS025");
} else if (v > 30) {
currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);
} else {
currentOwner = ecrecover(dataHash, v, r, s);
}

(upstream GnosisSafe.sol:286)

A signature is three numbers, conventionally called r, s and v. Safe overloads v — normally just “which of two recovery cases applies” — to pick one of four signature flavours. ecrecover is a built-in function that recovers the signer’s address from a signature and the message hash; the recovered address must turn out to be an owner. The excerpt shows three flavours; just below it, the original handles v == 0 (an EIP-1271 contract signature, with the owner address packed into r) and runs the final ownership checks.

v Meaning
27 / 28 EIP-712 typed data (v = 27 + recid)
31 / 32 eth_sign — the contract subtracts 4, so the client must add it
1 on-chain approveHash, with the approver packed into r
0 EIP-1271 contract signature, with the owner packed into r

And the check that bites is one line below this excerpt: each recovered owner must be strictly greater than the previous one. So the signatures must be sorted ascending by signer address, or the call reverts with GS026.

What the client has to get right

The client computes the same hash the contract computes, using ethers’ typed-data encoder (src/wallet.js:81) — if the two hashed different bytes, every signature would cover a hash the contract never checks. A signing loop then collects signatures one owner at a time, stopping once it has the threshold, and hands the sorted bundle to a sender (src/wallet.js:250).

The bundle the contract expects is built in three steps:

1
2
3
4
5
6
signatures.sort((left, right) => left.signer.toLowerCase().localeCompare(right.signer.toLowerCase()));
let signatureBytes = "0x";
for (const sig of signatures) {
signatureBytes += sig.data.slice(2);
}
return signatureBytes;

src/wallet.js:129. localeCompare on the lowercased addresses puts them in ascending order; anything else fails GS026 on chain. slice(2) strips each signature’s 0x prefix so the bytes concatenate into one hex string, which is the format execTransaction expects.

The third sharp edge is a two-character rewrite. personal_sign produces a v of 27 or 28 (0x1b or 0x1c at the end of the signature); Safe treats v > 30 as “eth_sign, subtract 4”, so the client must add 4 itself. The original line (src/main.js:131) signs the hash and runs .replace(/1b$/, "1f") and .replace(/1c$/, "20") over the result — 31 or 32 instead of 27 or 28. Get it wrong and ecrecover recovers a different address, which fails GS026, with an error message that says nothing about signatures.

The client also predicts its own Safe address before deployment (src/utils.js:6). CREATE2 — an Ethereum instruction that deploys a contract to an address derived from its code and a chosen salt — makes proxy addresses deterministic: one audited singleton (the master copy of the contract) serves every Safe, a factory deploys small proxies (forwarders with no logic of their own) pointing at it, and upgrading the wallet is one transaction for all of them.

Trade-offs

m-of-n versus n-of-n is a governance decision, not a technical one. Unanimity is immune to one compromised key but brittle: one lost hardware wallet or one unreachable co-signer bricks the treasury forever. m-of-n buys liveness at the price of accepting that m colluding — or coerced — owners can move funds. There is no cryptographic way around that.

The gas-refund fields are inside the signed hash, and that is deliberate. execTransaction refunds gasUsed + baseGas × gasPrice to refundReceiver (or tx.origin — the address that originally sent the transaction). A malicious baseGas/gasPrice in an otherwise legitimate signed transaction would drain the Safe in the relayer’s favour — a relayer is a service that submits your signed transaction and is repaid from within it. So those fields are part of what the owners sign, and a client that builds the hash from a subset of the fields is exploitable.

Griefing is cheap. Anybody who can submit a transaction can bump the nonce, invalidating signatures you have already collected. No funds are lost, but the coordination has to start over. On-chain approveHash confirmations are permanent noise in an indexer.

A module can move the entire balance with zero owner signatures and no guard check. The mitigation is entirely procedural: enable modules sparingly, prefer MultiSendCallOnly (a Safe batching helper limited to plain calls), and remember that disabling a module also needs the linked-list predecessor.

The client is where the difficulty lives. The contract is simple and audited; the hard parts are producing byte-exact signatures (the v rewriting, the sorted concatenation, the GS026 rule) and the off-chain coordination around them. That is why the real Safe ecosystem is a transaction service plus an SDK plus a web UI, and not a contract.

What I would do differently

Use the official SDK. Every one of the sharp edges above — the v rewrite, the signature ordering, the domain separator, CREATE2 derivation — is already solved in @gnosis.pm/safe-core and @safe-global/safe-gateway. Re-implementing them to “understand them better” is how a client ends up with a nine-parameter setup call that the deployed contract does not recognise.

Fix the two bugs that are actually in this repository. src/wallet.js:41 calls safe.getModulesPaginated(AddressOne, 10) with AddressOne never defined; the resulting ReferenceError is swallowed by the surrounding catch, so the call can never work and the code always falls back to handler.getModules(). And the local ABI declares a nine-argument setup(address[],uint256,address,bytes,address,address,uint256,address,uint256) — the canonical mainnet 1.3.0 singleton at 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552 has the eight-argument version, and the published safe-deployments artifacts all use eight. As it stands, Safe.setup() in this repository would send calldata the deployed Safe cannot decode, fall through to the fallback handler, and revert.

Add the tests. There are none — no reentrancy test, no signature-ordering test, no nonce-replay test, no module-abuse test, no guard-veto test. The README is still the stock Hardhat (a development framework for Ethereum contracts) sample README describing a contract that does not exist in the tree, dotenv — a library that loads settings from a .env file — is a declared dependency that is never imported, and web3 (a JavaScript library for talking to Ethereum) is instantiated and passed around but never used. None of that matters for a contract; all of it matters for a client.

And make the deployment target real. The default RPC — the HTTP endpoint a client sends requests to — is https://test.lixb.io and the signer service is https://safebox-test.hicom.com/... — both of which are dead today. A client that talks to a chain that no longer exists is a good way to learn the protocol and a bad way to ship one.

The version story is the part worth remembering: v0.1.0’s submitTransaction / confirmTransaction / executeTransaction and its isExecuted flag are not outdated idioms, they are a different architecture — a stateful queue with a cursor — and the industry moved to a stateless, signature-carried design. The move was driven by the costs the old model imposed: unbounded storage, griefable pending transactions, and a gas cost that grew with usage.

References