Skip to main content

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, BalanceProxy verifies 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.

Use the app

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 SafeRouter as a 6th owner
  • You set or keep the threshold at 3
  • SafeRouter provides 1 approval signature automatically during execution
  • The remaining 2 signatures come from human owners as usual
note

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:

  1. Owners sign the Safe transaction off-chain using their usual process (Safe UI, hardware wallets, etc.). They collect threshold - 1 signatures — one slot is reserved for SafeRouter.

  2. One owner pushes the transaction by calling SafeRouter with:

    • The BalanceProxy contract address
    • The balance constraints (postBalances or diffs) — the declared outcome
    • The Safe address
    • The SafeTx struct containing the target call and the collected signatures, plus routerSigPosition indicating where SafeRouter's signature should be inserted

    This is the step that is clear-signed: the submitter's hardware wallet displays the SafeRouter calldata, including the declared balance changes, giving them a human-readable guarantee of the outcome before broadcasting.

  3. SafeRouter validates the context:

    • The caller must be an existing owner of the Safe
    • SafeRouter itself must be an owner of the Safe
    • The Safe threshold must be ≥ 2
    • The provided signatures count must be exactly threshold - 1
  4. SafeRouter stores a commitment — a hash of (balanceProxy address, executeSafeTransaction calldata) — in a storage slot, then calls BalanceProxy. This allows BalanceProxy to:

    • Record pre-execution balances
    • Call back into SafeRouter.executeSafeTransaction to run the Safe transaction
    • Record post-execution balances
    • Enforce the declared constraints
  5. Inside the BalanceProxy callback, SafeRouter.executeSafeTransaction is invoked:

    • It recomputes the expected hash and verifies msg.sender is the exact BalanceProxy instance 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.execTransaction with the complete signatures
  6. BalanceProxy checks constraints after executeSafeTransaction returns:

    • 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:

FunctionCheck typeMetadata
safeExecuteWithPostBalancesAbsolute minimum balance after executionNo
safeExecuteWithDiffsSigned balance delta (after − before)No
safeExecuteWithPostBalancesMetaAbsolute minimum balance after executionYes
safeExecuteWithDiffsMetaSigned 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: SafeRouter stores a hash of (balanceProxy address, executeSafeTransaction calldata) in a private storage slot before calling BalanceProxy. The executeSafeTransaction callback recomputes the hash with msg.sender and reverts with UnauthorizedSafeExecution if it does not match. This ensures only the exact BalanceProxy instance that was passed in can trigger the callback — not any other contract.
  • Ownership checks: Both the external caller and SafeRouter itself are verified as Safe owners before any execution begins.
  • Threshold enforcement: The Safe's threshold must be ≥ 2 (since SafeRouter occupies exactly one slot, at least one human co-signer is always required).
  • Only Call operations: delegatecall and create Safe operations are rejected; only standard call is supported.
  • Atomic revert: Because BalanceProxy checks constraints after the entire Safe execution, a failed constraint reverts everything — the Safe transaction is fully undone.