Safe Multisig Support
What is Safe?
Safe (formerly Gnosis Safe) is the most widely used smart-contract-based multisig wallet in the Ethereum ecosystem. Rather than a single private key controlling an account, Safe requires multiple owners to collectively approve a transaction before it can execute. This is essential for teams and protocols that need shared custody over funds.
A Safe is configured with:
- A list of owner addresses (can be EOAs or other smart contracts)
- A threshold — the minimum number of owner signatures required to execute a transaction
For example, a 3-of-5 Safe requires at least 3 out of 5 owners to sign before any transaction goes through.
The Problem: Blind Signing is Even Worse for Multisig
Multisig setups are used precisely because the funds at stake are significant. Yet Safe transactions face a worse blind signing problem than ordinary EOA transactions.
When a Safe owner signs, they are not signing the target transaction directly — they are signing a metatransaction: a Safe-specific typed data structure whose data field contains the actual calldata destined for the target contract. Hardware wallets display this data field as raw hex, and whitelisting the infinite space of possible calldatas is completely infeasible. Unlike a plain EOA transaction where a wallet at least has a chance to recognise a known function selector, a Safe transaction adds an extra layer of indirection: the signer has no reliable way to verify what the inner calldata will actually do.
The Bybit hack demonstrated this at scale — even sophisticated teams with multisig setups can be tricked into signing malicious transactions they cannot read.
How SafeRouter Solves This
SafeRouter is a periphery contract built on top of BalanceProxy that brings balance-based clear signing to Safe multisig wallets.
The core idea is simple: instead of having one team member push the collected signatures directly to the Safe, they route the execution through SafeRouter, which wraps the Safe transaction inside BalanceProxy's balance-checking logic. This means:
- The actual Safe transaction still executes as normal.
- After execution,
BalanceProxyverifies that the declared balance constraints were met. - If any constraint is violated, the entire transaction reverts — including the Safe execution.
What gets clear-signed here is the transaction-pushing step — the on-chain call that actually submits collected signatures and executes the Safe transaction. The person submitting that call uses a hardware wallet that can display the balance changes declared in the SafeRouter calldata, giving them a human-readable guarantee of the outcome before the transaction lands on-chain.
app.erc8009.xyz handles this entire flow for you. There is no need to manually assemble calldata, manage signature positions, or interact with the contracts directly.
Setup
To use SafeRouter, it must be added as an owner of the Safe and the Safe's threshold must be at least 2.
SafeRouter occupies one signing slot. The remaining threshold - 1 slots are filled by the human co-signers as usual.
For example, in a 3-of-5 Safe:
- You add
SafeRouteras a 6th owner - You set or keep the threshold at 3
SafeRouterprovides 1 approval signature automatically during execution- The remaining 2 signatures come from human owners as usual
SafeRouter is a stateless singleton contract — one deployment serves all Safes on a given network. You do not deploy a new SafeRouter per Safe.
Execution Flow
Once SafeRouter is an owner, the flow works as follows:
-
Owners sign the Safe transaction off-chain using their usual process (Safe UI, hardware wallets, etc.). They collect
threshold - 1signatures — one slot is reserved forSafeRouter. -
One owner pushes the transaction by calling
SafeRouterwith:- The
BalanceProxycontract address - The balance constraints (
postBalancesordiffs) — the declared outcome - The Safe address
- The
SafeTxstruct containing the target call and the collected signatures, plusrouterSigPositionindicating whereSafeRouter's signature should be inserted
This is the step that is clear-signed: the submitter's hardware wallet displays the
SafeRoutercalldata, including the declared balance changes, giving them a human-readable guarantee of the outcome before broadcasting. - The
-
SafeRoutervalidates the context:- The caller must be an existing owner of the Safe
SafeRouteritself must be an owner of the Safe- The Safe threshold must be ≥ 2
- The provided signatures count must be exactly
threshold - 1
-
SafeRouterstores a commitment — a hash of(balanceProxy address, executeSafeTransaction calldata)— in a storage slot, then callsBalanceProxy. This allowsBalanceProxyto:- Record pre-execution balances
- Call back into
SafeRouter.executeSafeTransactionto run the Safe transaction - Record post-execution balances
- Enforce the declared constraints
-
Inside the
BalanceProxycallback,SafeRouter.executeSafeTransactionis invoked:- It recomputes the expected hash and verifies
msg.senderis the exactBalanceProxyinstance that was passed in — any other caller is rejected - It constructs the full signature set by inserting its own approved-hash signature at
routerSigPosition - It calls
safe.execTransactionwith the complete signatures
- It recomputes the expected hash and verifies
-
BalanceProxychecks constraints afterexecuteSafeTransactionreturns:- If all constraints pass → the transaction completes successfully
- If any constraint fails → the entire transaction reverts, fully unwinding the Safe execution
Submitting owner
│
└─▶ SafeRouter.safeExecuteWithDiffs(balanceProxy, diffs, safe, safeTx)
│ [checks: caller is owner, SafeRouter is owner, threshold ≥ 2]
│ [stores commitment hash keyed on (balanceProxy, calldata)]
│
└─▶ BalanceProxy.proxyCallDiffs(diffs, [], SafeRouter, executeSafeTransaction calldata, [])
│ [records pre-balances]
│
└─▶ SafeRouter.executeSafeTransaction(safe, safeTx)
│ [verifies msg.sender matches stored commitment]
│ [inserts SafeRouter approved-hash signature at routerSigPosition]
│
└─▶ safe.execTransaction(to, value, data, ...)
[executes the actual Safe transaction]
│
◀───────────┘
[records post-balances]
[checks diffs — reverts entire call if violated]
Choosing Between postBalances and diffs
SafeRouter exposes four entry points:
| Function | Check type | Metadata |
|---|---|---|
safeExecuteWithPostBalances | Absolute minimum balance after execution | No |
safeExecuteWithDiffs | Signed balance delta (after − before) | No |
safeExecuteWithPostBalancesMeta | Absolute minimum balance after execution | Yes |
safeExecuteWithDiffsMeta | Signed balance delta (after − before) | Yes |
The *Meta variants accept an additional BalanceMetadata[] argument containing symbol and decimals for each checked token. This data is validated on-chain against the actual token's metadata and is intended for hardware wallet display — the device can read it directly from calldata rather than querying the token contract separately.
Use diffs when provenance matters. Absolute postBalances only assert that an address holds at least a given amount after execution — a third party could fund the address to satisfy the check. Diff-based checks constrain the change, making them harder to manipulate.
Security Properties
- Unauthorized callback prevention:
SafeRouterstores a hash of(balanceProxy address, executeSafeTransaction calldata)in a private storage slot before callingBalanceProxy. TheexecuteSafeTransactioncallback recomputes the hash withmsg.senderand reverts withUnauthorizedSafeExecutionif it does not match. This ensures only the exactBalanceProxyinstance that was passed in can trigger the callback — not any other contract. - Ownership checks: Both the external caller and
SafeRouteritself are verified as Safe owners before any execution begins. - Threshold enforcement: The Safe's threshold must be ≥ 2 (since
SafeRouteroccupies exactly one slot, at least one human co-signer is always required). - Only
Calloperations:delegatecallandcreateSafe operations are rejected; only standardcallis supported. - Atomic revert: Because
BalanceProxychecks constraints after the entire Safe execution, a failed constraint reverts everything — the Safe transaction is fully undone.