EIP-1271

EIP-1271 Off-chain signatures

The Safe contracts and interface support off-chain EIP-1271 (opens in a new tab) 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 (opens in a new tab) to connect to the Safe and ethers (opens in a new tab).

Signing messages

It's possible to sign EIP-191 (opens in a new tab) compliant messages as well as EIP-712 (opens in a new tab) typed data messages.

Restrictions

  • Only Safe contracts of version >=1.1.0 are supported.
  • Signing off-chain messages with smart contract wallets isn't yet supported.

Enabling off-chain signing

Multiple dapps rely on on-chain signing. However, off-chain signing is the new default for Safe Apps that use the safe-apps-sdk (opens in a new tab) version >=7.11. In order to enable off-chain signing in a Safe App, the safe-apps-sdk package needs to be updated.

EIP-191 messages

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.

Example: Sign 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).

EIP-712 typed data

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.

Example: Sign typed data

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).

Fetching the signature asynchronously

You can fetch the signature asynchronously instead of waiting for the RPC response via the Safe Transaction Service (opens in a new tab).

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.

Example: Get Safe message hash

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.

Example: Loading message from Transaction Service

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.

Verifying a signature

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.

Example: Verify signature

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
}

Example dapps

Troubleshooting

Off-chain signing isn't being used

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

Confusion of messageHash and safeMessageHash

message, messageHash and safeMessageHash often get mixed up:

  • message or messageHash is used to verify or sign messages.
  • safeMessageHash is used to fetch data from the safe-transaction-service.