SignMessageLib
SignMessageLib is a Safe library contract whose sole purpose is to write a message hash into the calling Safe’s signedMessages storage mapping. It exists to let a Safe pre-approve an arbitrary message hash on-chain so that a later EIP-1271 isValidSignature call can return the magic value without needing any live signatures at verification time.
Why it exists
Safe’s CompatibilityFallbackHandler.isValidSignature supports two modes:
- Non-empty signature: parse the signature bytes as a concatenation of owner signatures, verify each one, and return the magic value if the threshold is met. This is the live-signature path.
- Empty signature (
0x): wrap thehashargument in aSafeMessage(bytes)EIP-712 struct under Safe’s own domain, hash that wrapped struct, and look it up insignedMessages[wrappedHash]. If the stored value is non-zero, return the magic value. This is the pre-approved-hash path.
The pre-approved-hash path is the only way to satisfy a verifier that calls the Safe’s isValidSignature asynchronously — for example, a Farcaster SignedKeyRequestValidator contract that looks up the Safe’s consent after an off-chain transaction flow. The verifier cannot pass live signatures; it can only call isValidSignature(digest, ""). The Safe must have stored consent ahead of time.
SignMessageLib is the mechanism for that storage. It is called via DELEGATECALL so that msg.sender and storage context are the Safe’s, meaning the write lands in the Safe’s own signedMessages mapping rather than in any external storage.
The function
contract SignMessageLib {
function signMessage(bytes calldata _data) external {
bytes32 msgHash = getMessageHash(_data);
signedMessages[msgHash] = 1;
emit SignMsg(msgHash);
}
}
The signedMessages storage slot is declared in Safe’s own contract at a fixed position, so the DELEGATECALL writes into the correct slot in the Safe’s storage.
getMessageHash(data) performs the Safe-specific EIP-712 wrapping: it computes hashStruct(SafeMessage(bytes message)) under Safe’s domain separator. This is NOT the same as the raw input digest — it is a wrapped version. The CompatibilityFallbackHandler.isValidSignature applies the same wrapping when it looks the hash up, so the two sides agree on the key used in the storage mapping.
How the Protocol Kit uses it
The Safe Protocol Kit exposes getSignMessageLibContract to fetch the library for a given Safe version, and it provides hashSafeMessage to compute the outer wrapped hash. The full flow from the Safe Protocol Kit Message Signatures guide:
// 1. Get the library contract for the Safe version
const signMessageLibContract = await getSignMessageLibContract({
safeVersion: '1.4.1'
})
// 2. Compute the message hash (raw, unwrapped — the lib wraps internally)
const messageHash = hashSafeMessage(MESSAGE)
// 3. Encode the library call
const txData = signMessageLibContract.encode('signMessage', [messageHash])
// 4. Build a DELEGATECALL transaction to the library
const safeTransactionData: SafeTransactionDataPartial = {
to: signMessageLibContract.address,
value: '0',
data: txData,
operation: OperationType.DelegateCall // critical
}
// 5. Wrap it in a Safe transaction that the Safe owners will sign and execute
const signMessageTx = await protocolKit.createTransaction({
transactions: [safeTransactionData]
})
// 6. Collect signatures and execute
await protocolKit.executeTransaction(signMessageTx)
The result: one on-chain transaction, one storage write into signedMessages[wrappedHash], and permanent on-chain consent for the Safe to isValidSignature(messageHash, "0x") return the EIP-1271 magic value for that message.
Why DELEGATECALL
CALL to SignMessageLib would write into SignMessageLib’s own storage, which is useless — no Safe owns that storage, and no Safe’s isValidSignature ever reads from it. DELEGATECALL runs the library’s code in the Safe’s storage context, so the write hits the correct signedMessages slot inside the Safe itself.
This is why the library exists as a separate contract at all. Safe did not want signMessage directly in the main Safe contract (each deployed Safe would carry the code needlessly), so it factored out the function into a library and uses DELEGATECALL to execute it on demand. The library is deployed once per chain and reused by every Safe.
Relationship to CompatibilityFallbackHandler
SignMessageLib.signMessage(data) writes the storage.
CompatibilityFallbackHandler.isValidSignature(hash, "") reads the storage.
Both contracts use the exact same EIP-712 wrapping logic to compute the key into signedMessages, so the write from the library and the read from the handler hit the same slot. The wrapping schema is:
typeHash(SafeMessage(bytes message)) ‖ keccak256(abi.encode(bytes(message)))
hashed under the Safe’s own EIP-712 domain. The verifier passes in an inner hash, the handler wraps it into SafeMessage(bytes) under Safe’s domain, and the lookup is against the wrapped hash — not the inner hash. This indirection means the same inner hash can be pre-approved under multiple Safes (each writes its own wrapped hash into its own storage) without collision.
Applied example
The Creating a Farcaster Account by Hand walkthrough performs this exact flow manually with cast: compute the Farcaster EIP-712 digest, encode a signMessage(digest) call to SignMessageLib, execute the Safe transaction, and then watch Farcaster’s validator call back into Safe.isValidSignature(digest, "") and succeed. The Protocol Kit collapses that entire walkthrough into the six-step code block above.
Connections
- Safe Protocol Kit: the SDK wrapper around
SignMessageLib - Safe Protocol Kit Message Signatures: the flow that uses the library
- EIP-1271: Standard Signature Validation Method for Contracts: the interface the stored hash ultimately satisfies
- EIP-1271 isValidSignature: the empty-signature pre-approved-hash mode
- EIP-712: Typed structured data hashing and signing: the wrapping the library uses internally
- Smart Account Signatures: the broader pattern of pre-approved hashes as on-chain signatures
- Creating a Farcaster Account by Hand: applied example with manual
castcommands