Message signatures
Using the Protocol Kit, this guide explains how to generate and sign messages from a Safe account, including plain string messages and EIP-712 JSON messages.
Before starting, check this guide's setup.
Prerequisites
Steps
Install dependencies
_10yarn install @safe-global/protocol-kit
Create a message
Messages can be plain strings or valid EIP-712 typed data structures.
_10// An example of a string message_10const STRING_MESSAGE = "I'm the owner of this Safe account"
_47// An example of a typed data message_47const TYPED_MESSAGE = {_47 types: {_47 EIP712Domain: [_47 { name: 'name', type: 'string' },_47 { name: 'version', type: 'string' },_47 { name: 'chainId', type: 'uint256' },_47 { name: 'verifyingContract', type: 'address' }_47 ],_47 Person: [_47 { name: 'name', type: 'string' },_47 { name: 'wallets', type: 'address[]' }_47 ],_47 Mail: [_47 { name: 'from', type: 'Person' },_47 { name: 'to', type: 'Person[]' },_47 { name: 'contents', type: 'string' }_47 ]_47 },_47 domain: {_47 name: 'Ether Mail',_47 version: '1',_47 chainId: Number(chainId),_47 verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC'_47 },_47 primaryType: 'Mail',_47 message: {_47 from: {_47 name: 'Cow',_47 wallets: [_47 '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',_47 '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'_47 ]_47 },_47 to: [_47 {_47 name: 'Bob',_47 wallets: [_47 '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',_47 '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57',_47 '0xB0B0b0b0b0b0B000000000000000000000000000'_47 ]_47 }_47 ],_47 contents: 'Hello, Bob!'_47 }_47}
The createMessage
method in the Protocol Kit allows for creating new messages and returns an instance of the EthSafeMessage
class. Here, we are passing TYPED_MESSAGE
, but STRING_MESSAGE
could also be passed.
_10let safeMessage = protocolKit.createMessage(TYPED_MESSAGE)
The returned safeMessage
object contains the message data (safeMessage.data
) and a map of owner-signature pairs (safeMessage.signatures
). The structure is similar to the EthSafeTransaction
class but applied for messages instead of transactions.
We use let
to initialize the safeMessage
variable because we will add the signatures later.
_10class EthSafeMessage implements SafeMessage {_10 data: EIP712TypedData | string_10 signatures: Map<string, SafeSignature> = new Map()_10 ..._10 // Other props and methods_10}
Sign the message
Once the safeMessage
object is created, we need to collect the signatures from the signers who will sign it.
Following our setup, we will sign a message with SAFE_3_4_ADDRESS
, the main Safe account in this guide. To do that, we first need to sign the same message with its owners: OWNER_1_ADDRESS
, OWNER_2_ADDRESS
, SAFE_1_1_ADDRESS
, and SAFE_2_3_ADDRESS
.
ECDSA signatures
This applies to OWNER_1_ADDRESS
and OWNER_2_ADDRESS
accounts, as both are EOAs.
The signMessage
method takes the safeMessage
together with a SigningMethod
and adds the new signature to the signMessage.signatures
map. Depending on the type of message, the SigningMethod
can take these values:
SigningMethod.ETH_SIGN
SigningMethod.ETH_SIGN_TYPED_DATA_V4
_25// Connect OWNER_1_ADDRESS_25protocolKit = await protocolKit.connect({_25 provider: RPC_URL_25 signer: OWNER_1_PRIVATE_KEY_25})_25_25// Sign the safeMessage with OWNER_1_ADDRESS_25// After this, the safeMessage contains the signature from OWNER_1_ADDRESS_25safeMessage = await protocolKit.signMessage(_25 safeMessage,_25 SigningMethod.ETH_SIGN_TYPED_DATA_V4_25)_25_25// Connect OWNER_2_ADDRESS_25protocolKit = await protocolKit.connect({_25 provider: RPC_URL_25 signer: OWNER_2_PRIVATE_KEY_25})_25_25// Sign the safeMessage with OWNER_2_ADDRESS_25// After this, the safeMessage contains the signature from OWNER_1_ADDRESS and OWNER_2_ADDRESS_25safeMessage = await protocolKit.signMessage(_25 safeMessage,_25 SigningMethod.ETH_SIGN_TYPED_DATA_V4_25)
Smart contract signatures
When signing with a Safe account, the SigningMethod
will take the value SigningMethod.SAFE_SIGNATURE
.
1/1 Safe account
This applies to the SAFE_1_1_ADDRESS
account, another owner of SAFE_3_4_ADDRESS
.
We need to connect the Protocol Kit to SAFE_1_1_ADDRESS
and the OWNER_3_ADDRESS
account (the only owner of SAFE_1_1_ADDRESS
) and sign the message.
_27// Create a new message object_27let messageSafe1_1 = await createMessage(TYPED_MESSAGE)_27_27// Connect OWNER_3_ADDRESS and SAFE_1_1_ADDRESS_27protocolKit = await protocolKit.connect({_27 provider: RPC_URL_27 signer: OWNER_3_PRIVATE_KEY,_27 safeAddress: SAFE_1_1_ADDRESS_27})_27_27// Sign the messageSafe1_1 with OWNER_3_ADDRESS_27// After this, the messageSafe1_1 contains the signature from OWNER_3_ADDRESS_27messageSafe1_1 = await signMessage(_27 messageSafe1_1,_27 SigningMethod.SAFE_SIGNATURE,_27 SAFE_3_4_ADDRESS // Parent Safe address_27)_27_27// Build the contract signature of SAFE_1_1_ADDRESS_27const signatureSafe1_1 = await buildContractSignature(_27 Array.from(messageSafe1_1.signatures.values()),_27 SAFE_1_1_ADDRESS_27)_27_27// Add the signatureSafe1_1 to safeMessage_27// After this, the safeMessage contains the signature from OWNER_1_ADDRESS, OWNER_2_ADDRESS and SAFE_1_1_ADDRESS_27safeMessage.addSignature(signatureSafe1_1)
When signing with a child Safe account, we need to specify the parent Safe address to generate the signature based on the version of the contract.
2/3 Safe account
This applies to the SAFE_2_3_ADDRESS
account, another owner of SAFE_3_4_ADDRESS
.
We need to connect the Protocol Kit to SAFE_2_3_ADDRESS
and the OWNER_4_ADDRESS
and OWNER_5_ADDRESS
accounts (owners of SAFE_2_3_ADDRESS
) and sign the message.
_41// Create a new message object_41let messageSafe2_3 = await createMessage(TYPED_MESSAGE)_41_41// Connect OWNER_4_ADDRESS and SAFE_2_3_ADDRESS_41protocolKit = await protocolKit.connect({_41 provider: RPC_URL,_41 signer: OWNER_4_PRIVATE_KEY,_41 safeAddress: SAFE_2_3_ADDRESS_41})_41_41// Sign the messageSafe2_3 with OWNER_4_ADDRESS_41// After this, the messageSafe2_3 contains the signature from OWNER_4_ADDRESS_41messageSafe2_3 = await protocolKit.signMessage(_41 messageSafe2_3,_41 SigningMethod.SAFE_SIGNATURE,_41 SAFE_3_4_ADDRESS // Parent Safe address_41)_41_41// Connect OWNER_5_ADDRESS_41protocolKit = await protocolKit.connect({_41 provider: RPC_URL,_41 signer: OWNER_5_PRIVATE_KEY_41})_41_41// Sign the messageSafe2_3 with OWNER_5_ADDRESS_41// After this, the messageSafe2_3 contains the signature from OWNER_5_ADDRESS_41messageSafe2_3 = await protocolKit.signMessage(_41 messageSafe2_3,_41 SigningMethod.SAFE_SIGNATURE,_41 SAFE_3_4_ADDRESS // Parent Safe address_41)_41_41// Build the contract signature of SAFE_2_3_ADDRESS_41const signatureSafe2_3 = await buildContractSignature(_41 Array.from(messageSafe2_3.signatures.values()),_41 SAFE_2_3_ADDRESS_41)_41_41// Add the signatureSafe2_3 to safeMessage_41// After this, the safeMessage contains the signature from OWNER_1_ADDRESS, OWNER_2_ADDRESS, SAFE_1_1_ADDRESS and SAFE_2_3_ADDRESS_41safeMessage.addSignature(signatureSafe2_3)
After following all the steps above, the safeMessage
now contains all the signatures from the owners of the Safe.
Publish the signed message
As messages aren't stored in the blockchain, we must make them public and available to others by storing them elsewhere.
Safe messages can be stored on-chain and off-chain:
- Off-chain: Messages are stored in the Safe Transaction Service. This is the default option and doesn't require any on-chain interaction.
- On-chain: Messages are stored (opens in a new tab) in the Safe contract.
Safe supports signing EIP-191 (opens in a new tab) messages and EIP-712 (opens in a new tab) typed data messages all together with off-chain EIP-1271 (opens in a new tab) validation for signatures.
Off-chain messages
To use off-chain messages, we need to use the functionality from this guide and call the Safe Transaction Service API to store the messages and signatures.
We mentioned the utility of storing messages in the contract. Off-chain messages have the same purpose, but they're stored in the Safe Transaction Service. It stores the messages and signatures in a database. It's a centralized service, but it's open-source and can be deployed by anyone.
The Safe Transaction Service is used by Safe{Wallet} to store messages and signatures by default.
Propose the message
To store a new message, we need to call the addMessage
from the API Kit, passing the Safe address, an object with the message, and a signature from one owner.
_12// Get the signature from OWNER_1_ADDRESS_12const signatureOwner1 = safeMessage.getSignature(OWNER_1_PRIVATE_KEY) as EthSafeSignature_12_12// Instantiate the API Kit_12// Use the chainId where you have the Safe account deployed_12const apiKit = new SafeApiKit({ chainId })_12_12// Propose the message_12apiKit.addMessage(SAFE_3_4_ADDRESS, {_12 message: TYPED_MESSAGE, // or STRING_MESSAGE_12 signature: buildSignatureBytes([signatureOwner1])_12})
The message is now publicly available in the Safe Transaction Service with the signature of the owner who submitted it.
Confirm the message
To add the signatures from the remaining owners, we need to call the addMessageSignature
, passing the safeMessageHash
and a signature from the owner.
_25// Get the safeMessageHash_25const safeMessageHash = await protocolKit.getSafeMessageHash(_25 hashSafeMessage(TYPED_MESSAGE) // or STRING_MESSAGE_25)_25_25// Get the signature from OWNER_2_ADDRESS_25const signatureOwner2 = safeMessage.getSignature(OWNER_2_ADDRESS) as EthSafeSignature_25_25// Add signature from OWNER_2_ADDRESS_25await apiKit.addMessageSignature(_25 safeMessageHash,_25 buildSignatureBytes([signatureOwner2])_25)_25_25// Add signature from the owner SAFE_1_1_ADDRESS_25await apiKit.addMessageSignature(_25 safeMessageHash,_25 buildSignatureBytes([signatureSafe1_1])_25)_25_25// Add signature from the owner SAFE_2_3_ADDRESS_25await apiKit.addMessageSignature(_25 safeMessageHash,_25 buildSignatureBytes([signatureSafe2_3])_25)
At this point, the message stored in the Safe Transaction Service contains all the required signatures from the owners of the Safe.
The getMessage
method returns the status of a message.
_10const confirmedMessage = await apiKit.getMessage(safeMessageHash)
Safe{Wallet} (opens in a new tab) exposes to its users the list of off-chain messages signed by a Safe account.
_10https://app.safe.global/transactions/messages?safe=<NETWORK_PREFIX>:<SAFE_ADDRESS>
On-chain messages
Storing messages on-chain is less efficient than doing it off-chain because it requires executing a transaction to store the message hash in the contract, resulting in additional gas costs. To do this on-chain, we use the SignMessageLib
contract.
_10// Get the contract with the correct version_10const signMessageLibContract = await getSignMessageLibContract({_10 safeVersion: '1.4.1'_10})
We need to calculate the messageHash
, encode the call to the signMessage
function in the SignMessageLib
contract and create the transaction that will store the message hash in that contract.
_13const messageHash = hashSafeMessage(MESSAGE)_13const txData = signMessageLibContract.encode('signMessage', [messageHash])_13_13const safeTransactionData: SafeTransactionDataPartial = {_13 to: signMessageLibContract.address,_13 value: '0',_13 data: txData,_13 operation: OperationType.DelegateCall_13}_13_13const signMessageTx = await protocolKit.createTransaction({_13 transactions: [safeTransactionData]_13})
Once the transaction object is instantiated, the owners must sign and execute it.
_10// Collect the signatures using the signTransaction method_10_10// Execute the transaction to store the messageHash_10await protocolKit.executeTransaction(signMessageTx)
Once the transaction is executed, the message hash will be stored in the contract.
Validate the signature
On-chain
When a message is stored on-chain, the isValidSignature
method in the Protocol Kit needs to be called with the parameters messageHash
and 0x
. The method will check the stored hashes in the Safe contract to validate the signature.
_10import { hashSafeMessage } from '@safe-global/protocol-kit'_10_10const messageHash = hashSafeMessage(MESSAGE)_10_10const isValid = await protocolKit.isValidSignature(messageHash, '0x')
Off-chain
When a message is stored off-chain, the isValidSignature
method in the Protocol Kit must be called with the messageHash
and the encodedSignatures
parameters. The method will check the isValidSignature
function defined in the CompatibilityFallbackHandler
contract (opens in a new tab) to validate the signature.
_10const encodedSignatures = safeMessage.encodedSignatures()_10_10const isValid = await protocolKit.isValidSignature(_10 messageHash,_10 encodedSignatures_10)