latest
Search
⌃K
Links

EIP-1271 Off-Chain signatures

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.

Signing messages

It is possible to sign EIP-191 compliant messages as well as EIP-712 typed data messages.

Restrictions

  • Only Safe contracts of version >=1.1.0 are supported.
  • Signing off-chain messages with smart contract wallets is not 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 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.
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/<SafeMsgHash>.
An example message on Goerli can be found here: https://safe-transaction-goerli.safe.global/api/v1/messages/0x7f4032fd13f27c5fce4694a1a6b79f65c68656da4eee4347a414f5bcec915b39/.
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-goerli.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 is not 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 is not being used

If your signing requests fallback to on-chain signing this could be because of multiple reasons:
  • The Safe App is not 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 does not have a fallback handler set. This can happen if Safes were not created through the official interface such as a CLI or third party interface.
  • The Safe version is not 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.
Last modified 21d ago