The Safe contracts and interface support off-chain EIP-1271 signatures. However, because Safe is a smart account, the flow is slightly different from simple EOA signatures.
This doc explains signing and verifying messages off-chain. All examples use WalletConnect to connect to the Safe and ethers.
It's possible to sign EIP-191 compliant messages as well as EIP-712 typed data messages.
- Only Safe contracts of version
>=1.1.0
are supported. - Signing off-chain messages with smart contract wallets isn't yet supported.
Multiple dapps rely on on-chain signing.
However, off-chain signing is the new default for Safe Apps that use the safe-apps-sdk version >=7.11
.
In order to enable off-chain signing in a Safe App, the safe-apps-sdk
package needs to be updated.
To sign a message we have to call the signMessage
function and pass in the message as hex string.
The signing request will be blocked until the message is fully signed and then return the signature
as a string.
As Safe{Wallet} is a multi signature wallet, this process can take some time because multiple signers may need to sign the message.
import { hashMessage, hexlify, toUtf8Bytes } from 'ethers/lib/utils'
const signMessage = async (message: string) => {
const hexMessage = hexlify(toUtf8Bytes(message))
const signature = await connector.signMessage([safeAddress, hexMessage])
}
After signing a message it will be available in the Safe's Message list (Transactions -> Messages).
To sign typed data we have to call the signTypedData
function and pass in the typed data object.
The signing request will be blocked until the message is fully signed and then return the signature
as a string.
As Safe{Wallet} is a multi signature wallet, this process can take some time because multiple signers may need to sign the message.
const getExampleData = () => {
return {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Example: [{ name: 'content', type: 'string' }],
},
primaryType: 'Example',
domain: {
name: 'EIP-1271 Example DApp',
version: '1.0',
chainId: 1,
verifyingContract: '0x123..456',
},
message: {
content: 'Hello World!',
},
}
}
const signTypedData = async () => {
const typedData = getExampleData()
const signature = await connector.signTypedData([
safeAddress,
JSON.stringify(typedData),
])
}
After signing, the message will be available in the Safe's Message list (Transactions -> Messages).
You can fetch the signature asynchronously instead of waiting for the RPC response via the Safe Transaction Service.
To do so we have to generate a hash of the message
or typedData
using ethers hashMessage(message)
or _TypedDataEncoder.hash(domain, types, message)
and then compute the Safe message hash
by calling getMessageHash(messageHash)
on the Safe contract.
const getSafeInterface = () => {
const SAFE_ABI = [
'function getThreshold() public view returns (uint256)',
'function getMessageHash(bytes memory message) public view returns (bytes32)',
'function isValidSignature(bytes calldata _data, bytes calldata _signature) public view returns (bytes4)'
]
return new Interface(SAFE_ABI)
}
const getSafeMessageHash = async (
connector: WalletConnect,
safeAddress: string,
messageHash: string
) => {
// https://github.com/safe-global/safe-contracts/blob/main/contracts/handler/CompatibilityFallbackHandler.sol#L43
const getMessageHash = getSafeInterface().encodeFunctionData(
'getMessageHash',
[messageHash]
)
return connector.sendCustomRequest({
method: 'eth_call',
params: [{ to: safeAddress, data: getMessageHash }]
})
}
Then we can query the state of the message from the network-specific Transaction Service endpoint for messages: https://safe-transaction-<NETWORK>.safe.global/api/v1/messages/<SAFE_MSG_HASH>
.
For other network endpoints, see Available Services.
const fetchMessage = async (
safeMessageHash: string
): Promise<TransactionServiceSafeMessage | undefined> => {
const safeMessage = await fetch(
`https://safe-transaction-sepolia.safe.global/api/v1/messages/${safeMessageHash}`,
{
headers: { 'Content-Type': 'application/json' }
}
).then((res) => {
if (!res.ok) {
return Promise.reject('Invalid response when fetching SafeMessage')
}
return res.json() as Promise<TransactionServiceSafeMessage>
})
return safeMessage
}
A Safe message has the following format:
{
"messageHash": string,
"status": string,
"logoUri": string | null,
"name": string | null,
"message": string | EIP712TypedData,
"creationTimestamp": number,
"modifiedTimestamp": number,
"confirmationsSubmitted": number,
"confirmationsRequired": number,
"proposedBy": { "value": string },
"confirmations": [
{
"owner": { "value": string },
"signature": string
}
],
"preparedSignature": string | null
}
A fully signed message will have the status CONFIRMED
, confirmationsSubmitted >= confirmationsRequired
and a preparedSignature !== null
.
The signature of the message will be returned in the preparedSignature
field.
We verify the signature by calling the Safe contract's isValidSignature(hash, signature)
function on-chain. This function returns the MAGIC VALUE BYTES 0x20c13b0b
if the signature
is correct for the messageHash
.
Note: A common pitfall is to pass the safeMessageHash
to the isValidSignature
call which isn't correct. It needs to be the hash of the original message.
const MAGIC_VALUE_BYTES = '0x20c13b0b'
const isValidSignature = async (
connector: WalletConnect,
safeAddress: string,
messageHash: string,
signature: string
) => {
// https://github.com/safe-global/safe-contracts/blob/main/contracts/handler/CompatibilityFallbackHandler.sol#L28
const isValidSignatureData = getSafeInterface().encodeFunctionData(
'isValidSignature',
[messageHash, signature]
)
const isValidSignature = (await connector.sendCustomRequest({
method: 'eth_call',
params: [{ to: safeAddress, data: isValidSignatureData }]
})) as string
return isValidSignature?.slice(0, 10).toLowerCase() === MAGIC_VALUE_BYTES
}
If your signing requests fallback to on-chain signing this could be because of multiple reasons:
- The Safe App isn't using
safe-apps-sdk
version>=7.11.0
. - The Safe{Wallet} is set to always use on-chain signing. This can be toggled in the Settings of the Safe{Wallet} (Settings -> Safe Apps).
- The connected Safe doesn't have a fallback handler set. This can happen if Safes weren't created through the official interface such as a CLI or third party interface.
- The Safe version isn't compatible - off-chain signing is only available for Safes with version
>1.0.0
message
, messageHash
and safeMessageHash
often get mixed up:
message
ormessageHash
is used to verify or sign messages.safeMessageHash
is used to fetch data from thesafe-transaction-service
.