Safe Protocol Kit Message Signatures
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:
- Create the message —
protocolKit.createMessage(message) - Sign the message —
protocolKit.signMessage(safeMessage, signingMethod) - Publish the signed message — off-chain via
apiKit.addMessage/apiKit.addMessageSignatureor on-chain via aSignMessageLibdelegatecall transaction - 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
- Safe Protocol Kit: the SDK that provides these methods
- EthSafeMessage: the in-memory signature accumulator
- Safe SigningMethod: the enum that selects between ECDSA, EIP-712, and nested-Safe signing
- Safe Nested Contract Signatures: the recursive pattern for Safes owned by Safes
- SignMessageLib: the delegatecall target for on-chain message pre-approval
- Safe Transaction Service: the off-chain storage backend for messages and signatures
- EIP-712: Typed structured data hashing and signing: the hashing standard
- EIP-1271: Standard Signature Validation Method for Contracts: the verification standard the SDK wraps
- Smart Account Signatures: the verifier-side pattern this SDK feeds
- Off-Chain Signatures: the broader pattern
- Creating a Farcaster Account by Hand: applied example performing the same on-chain pre-approval flow manually