Safe Protocol Kit Message Signatures

source
safeprotocol-kitsignatureseip-712eip-1271sdktypescript

Official Safe guide describing the end-to-end flow for producing, publishing, and validating messages signed by a Safe account using the @safe-global/protocol-kit TypeScript SDK. It is the practical counterpart to EIP-712 and EIP-1271 — showing exactly which SDK methods to call for each step of the flow that those specifications describe abstractly.

The flow in four stages

A Safe message signature is produced in four stages, each backed by a distinct Protocol Kit or API Kit method:

  1. Create the message — protocolKit.createMessage(message)
  2. Sign the message — protocolKit.signMessage(safeMessage, signingMethod)
  3. Publish the signed message — off-chain via apiKit.addMessage / apiKit.addMessageSignature or on-chain via a SignMessageLib delegatecall transaction
  4. Validate the signature — protocolKit.isValidSignature(messageHash, signature)

The message itself can be either a plain string ("I'm the owner of this Safe account") or an EIP-712 typed-data object. The SDK handles both transparently through the same entry points.

Stage 1: Create

const safeMessage = protocolKit.createMessage(TYPED_MESSAGE)

createMessage returns an instance of the EthSafeMessage class:

class EthSafeMessage implements SafeMessage {
  data: EIP712TypedData | string
  signatures: Map<string, SafeSignature> = new Map()
  // Other props and methods
}

The signatures map starts empty. It is populated incrementally as each owner signs. This structural choice is identical to EthSafeTransaction — Safe models transactions and messages as signature accumulators rather than immutable signed blobs.

Stage 2: Sign

signMessage takes the safeMessage plus a SigningMethod enum value and adds a new entry to safeMessage.signatures. See Safe SigningMethod for the full enum semantics. Three cases matter:

ECDSA with an EOA owner

For plain EOA owners, connect the Protocol Kit to the owner’s private key and call signMessage with SigningMethod.ETH_SIGN_TYPED_DATA_V4 (for typed messages) or SigningMethod.ETH_SIGN (for string messages).

protocolKit = await protocolKit.connect({
  provider: RPC_URL,
  signer: OWNER_1_PRIVATE_KEY
})

safeMessage = await protocolKit.signMessage(
  safeMessage,
  SigningMethod.ETH_SIGN_TYPED_DATA_V4
)

The wallet produces a 65-byte ECDSA signature that the Protocol Kit inserts into the signatures map under the owner’s address.

Nested-Safe contract signatures

When another Safe is an owner of the target Safe, the nested Safe must produce an EIP-1271 contract signature. Use SigningMethod.SAFE_SIGNATURE and pass the parent Safe address as the third argument:

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

The nested Safe first collects signatures from its own owners (through the ECDSA path above), then those signatures are wrapped into a single contract signature via buildContractSignature. See Safe Nested Contract Signatures for the full recursive pattern.

Building the contract signature

After a nested Safe has all its owner signatures:

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

The resulting signatureSafe1_1 is a SafeSignature with the contract-signature v=0 discriminator, which Safe’s checkSignatures will resolve through a recursive call to the nested Safe’s isValidSignature. This signature is then added to the parent Safe’s safeMessage.signatures map alongside any EOA signatures and any other nested-Safe contract signatures.

Stage 3: Publish

Messages are not stored on the blockchain by default. They must be made public through one of two routes.

Off-chain: Safe Transaction Service

The Safe Transaction Service is an open-source centralized database used by Safe{Wallet} to store messages and signatures. The @safe-global/api-kit exposes two methods:

Propose the message — submit the first owner signature alongside the message itself:

const signatureOwner1 = safeMessage.getSignature(OWNER_1_PRIVATE_KEY) as EthSafeSignature

const apiKit = new SafeApiKit({ chainId, apiKey: 'YOUR_API_KEY' })

apiKit.addMessage(SAFE_3_4_ADDRESS, {
  message: TYPED_MESSAGE,
  signature: buildSignatureBytes([signatureOwner1])
})

Confirm the message — add each subsequent owner signature, keyed by the safeMessageHash:

const safeMessageHash = await protocolKit.getSafeMessageHash(
  hashSafeMessage(TYPED_MESSAGE)
)

await apiKit.addMessageSignature(
  safeMessageHash,
  buildSignatureBytes([signatureOwner2])
)

Once enough confirmations reach the Safe’s threshold, apiKit.getMessage(safeMessageHash) returns the complete signature set. Safe{Wallet} exposes the list at https://app.safe.global/transactions/messages?safe=<NETWORK>:<SAFE_ADDRESS>.

On-chain: SignMessageLib

On-chain publication writes the message hash into the Safe’s own signedMessages storage mapping through a delegatecall to SignMessageLib. This is more expensive (it costs gas), but it produces a purely on-chain attestation that does not depend on any off-chain service:

const signMessageLibContract = await getSignMessageLibContract({
  safeVersion: '1.4.1'
})

const messageHash = hashSafeMessage(MESSAGE)
const txData = signMessageLibContract.encode('signMessage', [messageHash])

const safeTransactionData: SafeTransactionDataPartial = {
  to: signMessageLibContract.address,
  value: '0',
  data: txData,
  operation: OperationType.DelegateCall
}

const signMessageTx = await protocolKit.createTransaction({
  transactions: [safeTransactionData]
})

// Collect signatures using signTransaction
// Execute the transaction
await protocolKit.executeTransaction(signMessageTx)

After execution, signedMessages[wrappedHash] inside the Safe contract is set to a non-zero value, and the CompatibilityFallbackHandler.isValidSignature will return the EIP-1271 magic value when queried with an empty signature.

Stage 4: Validate

protocolKit.isValidSignature(messageHash, signature) wraps the underlying EIP-1271 call. The signature argument distinguishes on-chain from off-chain mode:

On-chain validation — pass '0x' (empty bytes). The Safe’s fallback handler looks up the hash in signedMessages and returns the magic value if it finds a non-zero entry:

const messageHash = hashSafeMessage(MESSAGE)
const isValid = await protocolKit.isValidSignature(messageHash, '0x')

Off-chain validation — pass the encoded signatures blob. The Safe’s checkSignatures parses it, verifies each owner signature against the current owner set, and returns the magic value if the threshold is met:

const encodedSignatures = safeMessage.encodedSignatures()
const isValid = await protocolKit.isValidSignature(messageHash, encodedSignatures)

Both routes call CompatibilityFallbackHandler.isValidSignature on the Safe contract and receive the EIP-1271 magic value 0x1626ba7e on success. Both routes produce the same yes/no answer to the same question from the same caller — but they differ in whether the signatures live on-chain or only in the caller’s own memory / the Safe Transaction Service.

Why the guide matters

This guide is the missing link between the standards layer (EIP-712, EIP-1271, Off-Chain Signatures) and a working production implementation. It names the exact methods, enums, types, and API calls a developer uses, and it covers all the cases the standards leave implementation-defined: nested Safes, two publication modes, two validation modes, and the wrapping that Safe applies on top of raw EIP-712 digests.

The Creating a Farcaster Account by Hand walkthrough performs the on-chain SignMessageLib path manually with cast. The Protocol Kit reduces that entire walkthrough to the getSignMessageLibContract + createTransaction + executeTransaction sequence shown above.

Connections