Safe Nested Contract Signatures

concept
safeprotocol-kitsignatureseip-1271multisigrecursion

A Safe can be owned by other Safes. When that happens, the inner Safes must produce EIP-1271 contract signatures that the outer Safe’s checkSignatures can resolve. The Safe Protocol Kit automates this recursive flow through SigningMethod.SAFE_SIGNATURE and buildContractSignature.

The configuration

Consider a 3/4 Safe at SAFE_3_4_ADDRESS whose four owners are:

  • OWNER_1_ADDRESS — an EOA
  • OWNER_2_ADDRESS — an EOA
  • SAFE_1_1_ADDRESS — a 1/1 Safe whose sole owner is OWNER_3_ADDRESS (an EOA)
  • SAFE_2_3_ADDRESS — a 2/3 Safe whose owners are three EOAs, threshold 2

To sign a message with SAFE_3_4_ADDRESS, three of its four owners must sign. That means two EOAs can sign directly (ECDSA), one nested Safe must produce a 1-of-1 contract signature, and the other nested Safe must produce a 2-of-3 contract signature. The outer Safe does not care how the inner Safes reached their consent — it only needs a single SafeSignature per owner, encoded in the form its on-chain checkSignatures function can resolve.

Step 1: Each nested Safe collects its own signatures

For SAFE_1_1_ADDRESS, connect the Protocol Kit to the inner Safe and call signMessage with SigningMethod.SAFE_SIGNATURE, passing the parent Safe address:

let messageSafe1_1 = await createMessage(TYPED_MESSAGE)

protocolKit = await protocolKit.connect({
  provider: RPC_URL,
  signer: OWNER_3_PRIVATE_KEY,
  safeAddress: SAFE_1_1_ADDRESS
})

messageSafe1_1 = await signMessage(
  messageSafe1_1,
  SigningMethod.SAFE_SIGNATURE,
  SAFE_3_4_ADDRESS // Parent Safe address
)

The parentSafeAddress argument is critical: it tells the nested Safe which parent’s EIP-712 domain its signature must bind to. The inner Safe’s owner signs the message against SAFE_3_4_ADDRESS’s domain, not against SAFE_1_1_ADDRESS’s own domain. Without this, the outer checkSignatures would compute a different digest and reject the signature.

For SAFE_2_3_ADDRESS, the same pattern repeats with two different owners signing, because the inner Safe needs two signatures to meet its own 2-of-3 threshold:

// OWNER_4 signs
messageSafe2_3 = await signMessage(messageSafe2_3, SigningMethod.SAFE_SIGNATURE, SAFE_3_4_ADDRESS)

// OWNER_5 signs
messageSafe2_3 = await signMessage(messageSafe2_3, SigningMethod.SAFE_SIGNATURE, SAFE_3_4_ADDRESS)

Each EthSafeMessage instance (messageSafe1_1, messageSafe2_3) is an independent accumulator collecting signatures on behalf of its respective Safe.

Step 2: Build each nested Safe’s contract signature

Once an inner Safe has enough signatures to meet its own threshold, wrap them into a single contract signature:

const signatureSafe1_1 = await buildContractSignature(
  Array.from(messageSafe1_1.signatures.values()),
  SAFE_1_1_ADDRESS
)

const signatureSafe2_3 = await buildContractSignature(
  Array.from(messageSafe2_3.signatures.values()),
  SAFE_2_3_ADDRESS
)

buildContractSignature(signatures, signerAddress) produces a SafeSignature with:

  • signer = the nested Safe’s address
  • data = the encoded concatenation of the nested Safe’s own owner signatures
  • isContractSignature = true
  • staticPart = 65 bytes with v = 0, r = signerAddress, s = offset to dynamic data (Safe’s discriminator for contract signatures)

The resulting bytes match exactly what Safe’s checkSignatures expects: when it sees v = 0 during parsing, it interprets r as an address, looks at the dynamic tail to find the contract-signature bytes, and calls IERC1271(r).isValidSignature(txHash, signatureBytes). The EIP-1271 call returns the magic value if the nested Safe’s own checkSignatures is satisfied.

Step 3: Add the contract signatures to the outer Safe’s accumulator

Connect the Protocol Kit to SAFE_3_4_ADDRESS, collect the EOA signatures from OWNER_1_ADDRESS and OWNER_2_ADDRESS, and then add the two nested-Safe contract signatures:

safeMessage.addSignature(signatureSafe1_1)
safeMessage.addSignature(signatureSafe2_3)

The parent EthSafeMessage.signatures map now contains four SafeSignature entries — two ECDSA, two contract — keyed by owner address. At validation time, encodedSignatures() serializes all four into the concatenated bytes blob Safe expects, and the on-chain checkSignatures resolves each entry: ECDSA via ecrecover, contract via a recursive isValidSignature call.

The recursion

Nothing stops a nested Safe from itself being owned by other Safes. Each level of nesting adds one buildContractSignature layer: the innermost Safes collect their owners’ ECDSA signatures, wrap them into contract signatures, and hand them up to the level above. The level above treats the wrapped contract signatures as opaque — it only knows “this is a signature from this address, and isValidSignature says it’s valid”. The recursion unwinds at each level through the on-chain checkSignatures implementation.

Each recursive EIP-1271 call is a real staticcall into the nested Safe’s CompatibilityFallbackHandler. Gas costs accumulate with nesting depth, but the interface remains the same at every level: isValidSignature(hash, signature) returns the magic value 0x1626ba7e iff the nested Safe is satisfied.

Why this matters

Multisig-of-multisig is how organizations build governance hierarchies on Ethereum. A DAO’s treasury Safe might be owned by a board Safe and a reserve Safe; the board Safe might be owned by individual signer Safes. Without nested contract signatures, every action would require the lowest-level EOAs to sign directly for the top-level Safe, and the middle-layer Safes would have no role in the signing process.

With nested contract signatures, each Safe’s own threshold logic is enforced at its own level. The board Safe satisfies its threshold independently of the treasury Safe, the reserve Safe satisfies its threshold independently of the board Safe, and the treasury Safe only sees final contract signatures that already encode those consensus decisions. The Protocol Kit makes this work by abstracting the entire recursion behind SigningMethod.SAFE_SIGNATURE and buildContractSignature.

Connections