This is a technical blog post by the Gallery Engineering team.
Got questions? Reach out to Kaito on twitter.
A massive breakthrough for Web3 is enabling users to have self-sovereign ownership over their logins and keys. As a result, like most Dapps, here at Gallery we use an Ethereum wallet sign in instead of the dated email+password. Naturally, we prioritized Metamask and WalletConnect support to account for the majority of users before building out support for additional wallets.
Over the last half year, as the number of DAOs have steadily increased and the influence they hold over the broader NFT ecosystem continues to rise, we understood it was critical to support DAOs as a first-class citizen, and the very first step to doing so was enabling them to natively sign in and create an account.
To do so, we started with supporting sign in with Gnosis Multi-sig. Over the past year, Gnosis has emerged as the vault of choice for the majority of DAOs due to frictionless start up, intuitive transaction handling across multiple wallets signers, and reliable security. By supporting Gnosis sign in, the majority of DAOs will be able to sign in as a user and create their gallery.
While Gnosis utilizes WalletConnect to sign in, we learned that there were nuances to the signing flow that we had to account for. During the process of building Gnosis sign-in support, we were surprised to find that there was little to no documentation around an integration like this. So we decided to write it. This is the step-by-step guide we wish we had. If you’re building an app and looking to support Gnosis Safes, buckle up and read on!
This post explains how to enable an app to prompt users to sign a message with a Gnosis Safe multi-sig, wait until the transaction is confirmed + executed by the required multi-sig owners, and then validate the signature.
We'll be going over:
Note: We assume you are familiar with enabling web3 connections in your app, and that your app already supports message signing with WalletConnect. Here we are specifically integrating WalletConnect with Gnosis (our open source implementation). If you’d like more detail on other wallet connections, let us know.
The authentication mechanism that we originally built allowed users to connect and sign a message via WalletConnect using a wallet like Rainbow, but it didn't work when we tried it with a Gnosis Safe. Why?
Because Gnosis Safes are Contract Accounts (aka. smart contracts), which cannot sign messages in the same way as Externally Owned Accounts, or EOAs (aka. normal wallets). Wait, what are EOAs and Contract Accounts?
There are two types of Ethereum accounts: Externally Owned Accounts (EOA) and Contract Accounts.
You can learn more about Ethereum accounts in their official docs.
This affected us in two key ways:
1. How the message is signed
Today, with EOAs, signing a message is off-chain and direct. The frontend prompts the user to sign a message through a provider, and the user signs with their wallet, which passes the signature back to the frontend. Easy.
However, because Gnosis Safe doesn't have a private key to sign a message with, the multi-sig owners must instead execute a transaction on-chain that marks the message as "signed". This means that the frontend must listen on-chain for this transaction to be executed by the Gnosis Safe contract to know it is ready to be validated.
2. How we validate the signed message
Because Contract Accounts cannot sign a message the way an EOA does, EIP-1271 introduced a mechanism that allows smart contracts to sign a message and have it validated. Indeed, Gnosis supports EIP-1271. In order to validate a signature from them, we must instantiate the Gnosis Safe contract and use their implementation of the isValidSignature method, which returns true
if that particular Gnosis Safe has signed the provided message on-chain.
This contrasts from EOAs, where we immediately receive the signature when the user signs the message off-chain, and we validate it by deriving the public address from the signature.
Here is what our signing flow looks like for Gnosis Safe users:
SignMsg
event that the Gnosis Safe contract emits upon execution.isSignatureValid
on the contract.Compared to the signing flow for EOAs (using Metamask or WalletConnect):
First, your app must be able to allow a user to connect their wallet (or Gnosis Safe) via WalletConnect, and then prompt them to sign a message. This is the start of the signing flow and works the same for both EOAs and contract accounts.
If your app already supports message signing for EOAs via WalletConnect, you can move to the next step, because this means you can already connect to a Gnosis Safe.
If your app doesn't, there are docs online that can help you get there, or you can also look at our implementation as an example. (Happy to dive deeper here in a future post — let us know.)
The flows for Gnosis signing and EOA signing are basically the same until the user actually reacts to the sign message prompt in their wallet.
With EOAs, the signature will immediately be available for validation once the user signs with their wallet.
However, with Gnosis Safe, since signing a message requires an on-chain transaction, we must wait until the transaction is executed before we are able to validate it.
How do we know when the transaction has been executed?
We need to listen to the Gnosis Safe contract on-chain. When the transaction is approved by the multi-sig owners and executed, the Gnosis Safe contract emits a "SignMsg" event on-chain. A listener in our app will detect this event and invoke a callback that will let us proceed once the transaction is executed, such as validating the signature.
Here is how we set up a listener for the SignMsg
event. First we need to instantiate the Gnosis Safe contract in our app:
import { Contract } from '@ethersproject/contracts';
import GNOSIS_SAFE_CONTRACT_ABI from 'abis/gnosis-safe-contract.json';
// Instantiate the Gnosis Safe contract so that we can call the contract's methods, or set up listeners for it
// note on inputs:
// `address` is the contract address. ie the address of the Gnosis Safe that is trying to connect.
// `GNOSIS_SAFE_CONTRACT_ABI` is a json file that contains the contract's ABI
// `library` is a Web3Provider that is available via Web3React, like so:
// const { library } = useWeb3React();
const gnosisSafeContract = new Contract(address, GNOSIS_SAFE_CONTRACT_ABI, library);
An ABI is an interface definition that tells your app what methods and events are available on a contract. When using an ABI in a React app, you can place the definition in a .json
file somewhere in your app directory, and import it. Here's an example of an ABI for Gnosis Safe that defines the SignMsg
event. Often for published smart contracts, you can get the ABI via Etherscan. We grabbed the SignMsg
event definition from the Gnosis Safe SignMessageLib
contract on Etherscan here. (SignMessageLib
is what emits the event if you look at the source code)
// partial example of ABI for Gnosis Safe contract (this is just for the SignMsg event)
[
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "msgHash",
"type": "bytes32"
}
],
"name": "SignMsg",
"type": "event"
}
]
Once the Gnosis Safe contract is instantiated, we can set up our listener.
We do this by using the contract.on()
function (docs)
// Set up a listener for the SignMsg event
gnosisSafeContract.on('SignMsg', async (msgHash) => {
// Stuff to do when the event is detected, like validate the signature
console.log("SignMsg event detected")
};
Once we've detected the SignMsg event on-chain, we must validate that our message has been signed as expected. It's possible that the detected SignMsg event was emitted while signing a different message, so we want to check that our intended message has been signed.
We validate the signature by calling the isValidSignature
method on the Gnosis Safe contract, which is defined here.
It takes two arguments:
personal_sign
method. This is always 0x
, which denotes an empty string or an empty array of bytes.The message hash is a keccak256 hash of the message, formatted as an Ethereum signed message. The example below shows how you can generate the message hash yourself using the keccak256
and toUtf8Bytes
util functions from the ethersproject library.
import { keccak256 } from '@ethersproject/keccak256';
import { toUtf8Bytes } from '@ethersproject/strings';
// To turn the original message string into the message hash we need,
// format the message as a Ethereum signed message and then keccak256 hash it
const message = "This is the message that we prompted the user to sign.";
const prependedMessage = `\x19Ethereum Signed Message:\n${message.length}${message}`;
const messageHash = keccak256(toUtf8Bytes(prependedMessage));
Once you have the message hash, you can validate it on the contract:
// this magic value is the constant that is returned by the isValidSignature method if the signature has been signed.
// defined here: https://github.com/gnosis/safe-contracts/blob/2620a21c0844f23df39ea98438b82e378bb334f0/contracts/handler/CompatibilityFallbackHandler.sol
const GNOSIS_VALID_SIGNATURE_MAGIC_VALUE = '0x1626ba7e';
// call isValidSignature to verify that the message was signed by the Gnosis Safe
const magicValue = await gnosisSafeContract.isValidSignature(messageHash, '0x');
const messageWasSigned = magicValue === GNOSIS_VALID_SIGNATURE_MAGIC_VALUE;
Putting it all together:
// prompt user to sign message via WalletConnect
const signature = await connector.walletConnectProvider.connector.signPersonalMessage([
message,
address,
]);
// instantiate Gnosis Safe contract
const gnosisSafeContract = new Contract(address, GNOSIS_SAFE_CONTRACT_ABI, library);
// generate message hash
const prependedMessage = `\x19Ethereum Signed Message:\n${message.length}${message}`;
const messageHash = keccak256(toUtf8Bytes(prependedMessage));
// create listener that will listen for the SignMsg event on the Gnosis contract
const listenToGnosisSafeContract = new Promise((resolve) => {
gnosisSafeContract.on('SignMsg', async (msgHash) => {
// Upon detecting the SignMsg event, validate that the contract signed the message
const magicValue = await gnosisSafeContract.isValidSignature(messageHash, '0x');
const messageWasSigned = magicValue === GNOSIS_VALID_SIGNATURE_MAGIC_VALUE;
if (messageWasSigned) {
resolve(msgHash);
}
});
});
// start listening
await listenToGnosisSafeContract;
This should give you all the pieces you need to prompt a user to sign a message with a Gnosis Safe, wait until the transaction has been executed, and validate that the correct message was signed as expected. This will cost the user some gas, but Gnosis may support a gasless solution in the future.
Hopefully this helps more more apps support DAOs natively! If you have questions, feel free to reach out to us on Twitter or Discord.
If you're interested in solving problems like these, come join us at Gallery – we're hiring! Check out our open roles here.
Special thanks to the folks at Gnosis, especially Mikhail Mikheev on the engineering team, for being super helpful and answering all my questions on their discord.