Skip to content

feat: execute multi-owner Safe transactions without the Transaction Service - #35

Merged
aviggiano merged 2 commits into
Recon-Fuzz:mainfrom
aelmanaa:direct-exec-multisig
Nov 4, 2026
Merged

aviggiano merged 2 commits into
Recon-Fuzz:mainfrom
aelmanaa:direct-exec-multisig

Conversation

@aelmanaa

Copy link
Copy Markdown
Contributor

Relates to #33.

Problem

Without the Safe Transaction Service, a team has to collect owner signatures itself and call execTransaction. getExecTransactionData builds calldata with one signature, the sender's, so it only works for a Safe with threshold 1. A Safe with threshold > 1 on a chain without the service (common on new L2s and testnets) has no path in the library. Hardware wallets add a second problem: a script cannot sign the safeTxHash with the device it broadcasts with (--ledger/--trezor), which is why the README describes a two-step ffi signing workaround.

Solution

Two ways to gather approvals, which can be mixed:

  1. Offline signatures. Each owner signs the same safeTxHash, with sign(...) or any external tool. combineSignatures(safeTxHash, bytes[]) checks that each 65-byte signature belongs to a current owner, rejects duplicates and counts below the threshold, and sorts by owner address ascending. It accepts ECDSA signatures (v = 27/28, including Ledger EIP-712 output from sign), eth_sign signatures with v + 4 (v = 31/32, as sign produces for Trezor), and approved-hash entries (v = 1). Contract signatures (v = 0) are not supported.
  2. On-chain approvals. getApproveHashData(safeTxHash) builds the approveHash call. An owner sends it to the Safe as an ordinary transaction, so it works with --ledger or --trezor without the ffi signing step. getApprovedHashSignatures(safeTxHash, executor) returns v = 1 signatures for every owner with approvedHashes(owner, hash) set, plus the executor if it is an owner, since the Safe accepts the caller of execTransaction as approved.

Execution calldata:

  • getExecTransactionDataWithSignatures(to, data, signatures[, nonce])
  • getExecTransactionsDataWithSignatures(targets, datas, signatures[, nonce]) for MultiSend batches (DelegateCall, as for proposeTransactionsWithSignature)

The nonce overloads follow proposeTransactionWithSignature(..., nonce): they let a script prepare several transactions before any of them runs. ISafeSmartAccount gains getOwners, getThreshold, approvedHashes and approveHash. Existing functions are unchanged. The README has a short usage section.

Testing

test/SafeDirectExec.t.sol deploys a fresh 2-of-3 Safe (v1.4.1 SafeL2) through the canonical SafeProxyFactory on a Base fork and executes WETH transfers. 10 tests:

  • two offline signatures passed in unsorted order execute;
  • an eth_sign (v + 4) signature mixed with an ECDSA one executes;
  • one approveHash plus the executing owner executes;
  • one approveHash plus one offline signature, executed by a non-owner, executes;
  • a MultiSend batch executes;
  • two transactions prepared with explicit nonces 0 and 1 execute in sequence;
  • too few signatures, a non-owner signature, a duplicate owner, and a signature for another nonce revert.

With src/ stashed, the new test file does not compile, because the new functions are missing. With the change: forge test passes 39/39 (29 before), and forge fmt --check is clean (forge 1.8.7).

🤖 Generated with Claude Code

…ervice

getExecTransactionData builds calldata with only the sender's signature,
so direct execution works only for threshold-1 Safes. Add helpers to
gather approvals from several owners and build execTransaction calldata:

- combineSignatures: validates 65-byte signatures (ECDSA, eth_sign v+4,
  approved-hash v=1) against the owners and threshold, sorts by owner
- getApproveHashData: calldata for an owner's on-chain approveHash,
  which works with hardware wallets as an ordinary transaction
- getApprovedHashSignatures: v=1 signatures for owners that approved,
  plus the executing owner
- getExecTransactionDataWithSignatures and
  getExecTransactionsDataWithSignatures, with explicit-nonce overloads

Tests deploy a 2-of-3 Safe through the canonical SafeProxyFactory on a
Base fork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aviggiano

Copy link
Copy Markdown
Collaborator

Thanks, the signature handling and execution tests look good. I’ll merge once CI passes. I’ll follow up with separate examples for single calls and batches, plus tests showing that unapproved hashes and the wrong executor are rejected by the Safe.

@aviggiano
aviggiano merged commit d708d1c into Recon-Fuzz:main Nov 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants