How to handle Gnosis Safe connection and signature

Valerie Tan
Mighty Bear Games
Published in
13 min readMay 10, 2023

Hi, I am Valerie, one of the full-stack developers from Mighty Bear Games who has been part of the development of the MightyNet ecosystem. If this is the first time you are hearing about us, make sure you check out our MightyNet Genesis Pass and Big Bear Syndicate collections, or visit the MightyNet and check out what we have to offer!

And if you've been to the MightyNet and connected your wallet (or at least attempted to), you would've noticed that we offer various wallet connections, including the all-encompassing WalletConnect. We have this option since it covers many wallets, and it includes connecting to a specific multi-signature wallet that many of our key partners and ourselves use — the Gnosis Safe. With many of our important users preferring this, we have to make sure that the support for it in our MightyNet provides a seamless user experience. However, handling Gnosis safe is not as straightforward as other wallets (custodial or non-custodial), so if you need a generic guide on how to handle that, we've got you covered!

Example NextJs 13 App with Wagmi, RainbowKit, SIWE, NextAuth

In the following example, we will be creating a simple NextJS application using NextJS 13 and its new experimental feature — the App Router. However, even if your application does not use this feature, the guide below still applies. The following example also uses TailwindCSS, but it is not necessary if your project doesn't require it. Do make sure that you have fulfilled the requirements listed here. We will also be using the Goerli testnet.

Step 1 — Create NextJs app

Using the command npx create-next-app@latest with Typescript, ESLint, TailwindCSS, and experimental features enabled, your project structure should look something like this:

Next App project structure upon creation

Step 2 — Add the required packages

As mentioned, we would be using Wagmi, RainbowKit, SIWE, and NextAuth. Let's install Wagmi and RainbowKit first, and make sure we can create a connection to our Safe. We would also need to add ethersV5 and encoding as they are dependencies. The one-liner command here would be yarn add @rainbow-me/rainbowkit wagmi ethers@ˆ5 encoding (choose the latest version of ethers V5). The resulting package.json should look something like this:

{
"name": "nextapp-gnosis-example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@rainbow-me/rainbowkit": "^0.12.7",
"@types/node": "18.15.11",
"@types/react": "18.0.34",
"@types/react-dom": "18.0.11",
"autoprefixer": "10.4.14",
"encoding": "^0.1.13",
"eslint": "8.38.0",
"eslint-config-next": "13.3.0",
"ethers": "^5.7.2",
"next": "13.3.0",
"postcss": "8.4.21",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "3.3.1",
"typescript": "5.0.4",
"wagmi": "^0.12.10"
}
}

Step 2.1 — (Optional ) Use .prettier

I personally like to use .prettier for formatting purposes. To add that, create a .prettierrc in the project root directory with the following content:

{
"tabWidth": 4,
"useTabs": true,
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"requirePragma": false
}

Let's see what we have when we run a localhost in development mode, shall we? (that would be yarn dev or the npm equivalent if you are not using yarn!)

http://localhost:3000 at this point

Step 3 — Create our own home page with a connect button

It's time to strip down our page to the minimum so we can focus on working with RainbowKit! Replace the contents in app/layout.tsx with the following:

import "./globals.css";

export const metadata = {
title: "Connecting Gnosis Safe",
description:
"Connecting Gnosis Safe to a NextJS 13 app, handle signatures, and handle transactions",
viewport: "width=device-width, initial-scale=1.0",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<main className="grow flex flex-col">
<div className="flex flex-col grow basis-0 min-h-0">{children}</div>
</main>
</body>
</html>
);
}

For app/page.tsx , use the following content:

import { ConnectButton } from "@rainbow-me/rainbowkit";

export default function Home() {
return (
<div className="flex flex-col justify-center h-full">
<div className="w-full text-center text-xl">
Connect your Gnosis Safe using Rainbowkit!
</div>
<ConnectButton />
</div>
);
}

If you run your localhost now, you would realize that you will get an error as app/page.tsx is using components that only work as client components. We have two options for this:

  1. Make app/page.tsx a client component
  2. Create a wrapper (that is a client component) to wrap the connect button provided by RainbowKit.

I will use option 2 in this example, as this trick to wrap plugins and libraries into client component would come in very handy.

To create a wrapper for the connect button, lets create a client component with the file name components/ConnectWallet.tsx and use this newly created component in app/page.tsx instead of <ConnectButton /> provided by RainbowKit.

"use client";

import { ConnectButton } from "@rainbow-me/rainbowkit";

export default function ConnectWallet() {
return (
<>
<div className="text-lg justify-center mx-auto">
<ConnectButton />
</div>
</>
);
}

When we run our local server now… Oh no! We gotta setup our wagmi client!

The wagmi client, again, only works in a client component. Let's create another wrapper that handles our wagmi client (and in the later steps, this wrapper would do much more!). This component shall be components/AuthenticationWrapper.tsx (its called AuthenticationWrapper for a reason!)

"use client";
import { getDefaultWallets, RainbowKitProvider } from "@rainbow-me/rainbowkit";
import "@rainbow-me/rainbowkit/styles.css";
import { ReactNode } from "react";
import { configureChains, createClient, WagmiConfig } from "wagmi";
import { goerli } from "wagmi/chains";
import { publicProvider } from "wagmi/providers/public";

const { chains, provider, webSocketProvider } = configureChains([goerli], [publicProvider()]);

const { connectors } = getDefaultWallets({
appName: "My App for connecting Gnosis Safe",
chains,
});

const wagmiClient = createClient({
autoConnect: true,
connectors,
provider,
});

export default function AuthenticationWrapper({ children }: { children: ReactNode }) {
return (
<WagmiConfig client={wagmiClient}>
<RainbowKitProvider chains={chains}>{children}</RainbowKitProvider>
</WagmiConfig>
);
}

We then add this wrapper to app/layout.tsx (which, by default, is a server component!)

import AuthenticationWrapper from "@/components/AuthenticationWrapper";
import "./globals.css";

export const metadata = {
title: "Connecting Gnosis Safe",
description:
"Connecting Gnosis Safe to a NextJS 13 app, handle signatures, and handle transactions",
viewport: "width=device-width, initial-scale=1.0",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<main className="grow flex flex-col">
<AuthenticationWrapper>
<div className="flex flex-col grow basis-0 min-h-0">{children}</div>
</AuthenticationWrapper>
</main>
</body>
</html>
);
}

Run your local server, and you should now see this in http://localhost:3000

http://localhost:3000 at this point — We have a connect wallet button 🎉!

We can now try connecting our Gnosis wallet! Do make sure you are in your Safe app on the browser so you can use the WalletConnect QR for connection.

How to connect Safe with WalletConnect

Step 4 — Add authentication using SIWE and NextAuth

RainbowKit provides authentication library that works seamlessly with NextAuth and SIWE. Check out their respective guides to get a better understanding on how they work. The following example is largely based on RainbowKit's authentication guide.

1 — Add the relevant packages

The packages to add (using npm or yarn) would be next-auth@4.17.0 , siwe@ˆ2 and @rainbow-me/rainbowkit-siwe-next-auth . Install the latest version of siwe v2. We are also sticking to next-auth version 4.17.0 for simplicity and we do not have to implement our own auth/session endpoint.

2 — Add the relevant environment variables for NextAuth

According to NextAuth docs, we would need to add NEXTAUTH_URL and NEXTAUTH_SECRET to our environment variables. Add the .env file in your project root and add these environment variables.

3 — Add the next auth endpoint

Even though we are using App Router, we can still use the pages directory for API endpoints, which is what we are going to do only for NextAuth — until a stable version for NextAuth + SIWE with App Router is released. Create the file pages/api/auth/[...nextauth].ts with the following content (from NextAuth tutorials):

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { getCsrfToken } from "next-auth/react";
import { SiweMessage } from "siwe";

// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
export default async function auth(req: any, res: any) {
const providers = [
CredentialsProvider({
name: "Ethereum",
credentials: {
message: {
label: "Message",
type: "text",
placeholder: "0x0",
},
signature: {
label: "Signature",
type: "text",
placeholder: "0x0",
},
},
async authorize(credentials) {
try {
const siwe = new SiweMessage(JSON.parse(credentials?.message || "{}"));
const nextAuthUrl = new URL(process.env.NEXTAUTH_URL ?? "");

const result = await siwe.verify({
signature: credentials?.signature || "",
domain: nextAuthUrl.host,
nonce: await getCsrfToken({ req }),
});

if (result.success) {
return {
id: siwe.address,
};
}
return null;
} catch (e) {
return null;
}
},
}),
];

const isDefaultSigninPage = req.method === "GET" && req.query.nextauth.includes("signin");

// Hide Sign-In with Ethereum from default sign page
if (isDefaultSigninPage) {
providers.pop();
}

return await NextAuth(req, res, {
// https://next-auth.js.org/configuration/providers/oauth
providers,
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async session({ session, token }: { session: any; token: any }) {
session.address = token.sub;
session.user.name = token.sub;
session.user.image = "https://www.fillmurray.com/128/128";
return session;
},
},
});
}

4 — Customize your SIWE message options in the AuthenticationWrapperand add RainbowKitSiweNextAuthProvider with SessionProviderto enable authentication with the updated AuthenticationWrapper as follows (added getSiweMessageOptions , SessionProvider , and RainbowKitSiweNextAuthProvider):

"use client";
import { getDefaultWallets, RainbowKitProvider } from "@rainbow-me/rainbowkit";
import {
GetSiweMessageOptions,
RainbowKitSiweNextAuthProvider,
} from "@rainbow-me/rainbowkit-siwe-next-auth";
import "@rainbow-me/rainbowkit/styles.css";
import { SessionProvider } from "next-auth/react";
import { ReactNode } from "react";
import { configureChains, createClient, WagmiConfig } from "wagmi";
import { goerli } from "wagmi/chains";
import { publicProvider } from "wagmi/providers/public";

const { chains, provider, webSocketProvider } = configureChains([goerli], [publicProvider()]);

const { connectors } = getDefaultWallets({
appName: "My App for connecting Gnosis Safe",
chains,
});

const wagmiClient = createClient({
autoConnect: true,
connectors,
provider,
});

const getSiweMessageOptions: GetSiweMessageOptions = () => ({
statement: "Sign in to test your Safe connection!",
});

export default function AuthenticationWrapper({ children }: { children: ReactNode }) {
return (
<WagmiConfig client={wagmiClient}>
<SessionProvider>
<RainbowKitSiweNextAuthProvider getSiweMessageOptions={getSiweMessageOptions}>
<RainbowKitProvider chains={chains}>{children}</RainbowKitProvider>
</RainbowKitSiweNextAuthProvider>
</SessionProvider>
</WagmiConfig>
);
}

Now you can see why we wrap the wagmi client in a component called AuthenticationWrapper!

Run your localhost now. If you connect a commonly used non-custodial wallet such as Metamask, connection and verification works seamlessly. However, if you connect your Gnosis Safe wallet, during the verification process, even before you execute the transaction in your safe, your app throws the following error:

Error during signature verification with Gnosis Safe

Aw! Back in your local console, you would see that the signature is invalid, and that the signature is simply "0x". This is expected, as Gnosis Safe does not return the actual signature! A more detailed explanation can be found here.

Step 5 — Adding a provider for EIP-1271 signatures

What if we add a provider for siwe to verify with? According to the docs from SIWE, a provider is required when verifying EIP-1271 signatures. So let's create a utils file lib/utils/provider.ts and export a provider to use. We will be using an Alchemy provider in this case, so go ahead and create an account to obtain an API key to use. Remember to add the API key into your environment variables (accessible on client, so remember to add NEXT_PUBLIC_ as a prefix for your environment variable).

Feel free to use the same API Key and use alchemyProvider instead of publicProvider for your wagmi client.

import { ethers } from "ethers";
import { goerli } from "@wagmi/chains";

export const PROVIDER = new ethers.providers.AlchemyProvider(
goerli.id,
process.env.NEXT_PUBLIC_ALCHEMY_API_KEY
);

We then add this provider into the options for siwe.verify() in [...nextauth].ts :

     const result = await siwe.verify(
{
signature: credentials?.signature || "",
domain: nextAuthUrl.host,
nonce: await getCsrfToken({ req }),
},
{ provider: PROVIDER }
);

Run your localhost now, and if you try connecting to different types of wallets (let's use Sequence for a smart contract wallet), you get the following results:

  1. Metamask — everything sweet and perfect
  2. Sequence — able to connect, able to verify signature, even though there is an error in the server console
  3. Gnosis Safe — able to connect, but still errors out during signature verification.

So adding a provider still does not work for Gnosis Safe, because the signature itself is only "0x". To handle this, we first need to understand the basic flow of how signing messages on Gnosis Safe works.

When signing a message, the minimum number of signers of the multi-signature wallet needs to accept the signature first, then the transaction to execute needs to be called. Only when the transaction to execute is called, will the message be considered signed and verified. For Gnosis Safe, when this happens, a SignMsg event is emitted, and that is the event that we will listen to and rely on.

Step 6 — Handling Gnosis Safe signature (when the signature is 0x )

The verification happens on the node server (where the nextauth authorization happens) and not the client, so we cannot use the wagmi hooks to listen to the events. However, we do use the wagmi hooks for execution events when we use our Safe wallet to execute other types of transactions, but that would be covered in the future. For now, let's first add another utils function for gnosis at lib/utils/gnosis.ts to do our signature verification.

1 — Add the required package @safe-global/safe-deployments

2 — Verify that the wallet address is a smart contract wallet that exists on the network

// check if exists on network first
const byteCode = await PROVIDER.getCode(walletAddress);
if (!byteCode || ethers.utils.hexStripZeros(byteCode) == "0x") {
return false;
}

If the wallet address is not a smart contract, the byte code would be empty.

3 — Get the sign library safe deployment so that we can get the contract interface. We need the interface before we initialize the wallet address as a safe smart contract.

// use sign message library deployment to listen for events
const gnosisSafeDeployment = getSignMessageLibDeployment({
network: goerli.id.toString(),
});

if (!gnosisSafeDeployment) {
return false;
}

4 — Now that we have the contract interface, we can initialize the wallet as a smart contract:

const gnosisSafeContract = new ethers.Contract(
walletAddress,
gnosisSafeDeployment?.abi,
PROVIDER
);

5 — Now we hash the message using ethers, then get the message hash using the smart contract wallet so that we know what is the actual message hash we need to listen for:

const messageHash = ethers.utils.hashMessage(message);
// this is the message hash that would be emitted in the event SignMsg
const gnosisMessageHash = await gnosisSafeContract.getMessageHash(messageHash);

6 — Now for the most important portion — listening for the SignMsg event. Here, we create a Promise<boolean> that we can wait on, and once we get an event with the correct message hash, it would mean that the signature is verified. Also add a timeout so that we do not wait forever if the signature is not accepted.

let timeout: NodeJS.Timeout;
const waitForSignedEvent = new Promise<boolean>((resolve, reject) => {
const onMultiSigSigned = () => {
clearTimeout(timeout);
resolve(true);
};
timeout = setTimeout(() => {
gnosisSafeContract.removeListener("SignMsg", onMultiSigSigned);
reject(false);
}, 60000); // 60 seconds

gnosisSafeContract.on("SignMsg", async msgHash => {
if (msgHash == gnosisMessageHash) {
onMultiSigSigned();
}
});
});

waitForSignedEvent
.then(async value => {
if (value) {
return value;
}
return false;
})
.catch(err => {
console.error(err);
return false;
});
return await waitForSignedEvent;

We should have a full verification function like this:

import { ethers } from "ethers";
import { PROVIDER } from "./provider";
import { getSignMessageLibDeployment } from "@gnosis.pm/safe-deployments";
import { goerli } from "wagmi";

export default async function verifyGnosisSignature(walletAddress: string, message: string) {
// check if exists on network first
const byteCode = await PROVIDER.getCode(walletAddress);
if (!byteCode || ethers.utils.hexStripZeros(byteCode) == "0x") {
return false;
}

const gnosisSafeDeployment = getSignMessageLibDeployment({
network: goerli.id.toString(),
});

if (!gnosisSafeDeployment) {
return false;
}

const gnosisSafeContract = new ethers.Contract(
walletAddress,
gnosisSafeDeployment?.abi,
PROVIDER
);

const messageHash = ethers.utils.hashMessage(message);
// this is the message hash that would be emitted in the event SignMsg
const gnosisMessageHash = await gnosisSafeContract.getMessageHash(messageHash);

let timeout: NodeJS.Timeout;
const waitForSignedEvent = new Promise<boolean>((resolve, reject) => {
const onMultiSigSigned = () => {
clearTimeout(timeout);
resolve(true);
};
timeout = setTimeout(() => {
gnosisSafeContract.removeListener("SignMsg", onMultiSigSigned);
reject(false);
}, 60000); // 60 seconds

gnosisSafeContract.on("SignMsg", async msgHash => {
if (msgHash == gnosisMessageHash) {
onMultiSigSigned();
}
});
});

waitForSignedEvent
.then(async value => {
if (value) {
return value;
}
return false;
})
.catch(err => {
console.error(err);
return false;
});
return await waitForSignedEvent;
}

7 — Lets add this to our [...nextauth].ts authorize function!

async authorize(credentials) {
try {
const siwe = new SiweMessage(JSON.parse(credentials?.message || "{}"));
const nextAuthUrl = new URL(process.env.NEXTAUTH_URL ?? "");

if (credentials?.signature == "0x") {
const isValidSignature = await verifyGnosisSignature(
siwe.address,
siwe.prepareMessage()
);
if (isValidSignature) {
return {
id: siwe.address,
};
}
} else {
const result = await siwe.verify(
{
signature: credentials?.signature || "",
domain: nextAuthUrl.host,
nonce: await getCsrfToken({ req }),
},
{ provider: PROVIDER }
);

if (result.success) {
return {
id: siwe.address,
};
}
}
return null;
} catch (e) {
return null;
}
},

Time to test everything out! Run your localhost and connect to your Safe — and you should be able to verify your signature now!

I've added some text to show what my wallet connection status is, feel free to add your own.

Connecting a Safe wallet and signing a message for verification

The full project can be found here.

Hope this helps you setup your own project to connect with the Gnosis Safe wallet! Do leave your feedback and comments below, and leave some claps if this guide has been useful! Also, leave your comments below if you want a guide on how we listen to Safe transactions in the background, store pending transactions, and restore them and resume listening to them!

Special thanks to Deric Atienza for helping us figure all these out while we were developing our web app for minting the Big Bear Syndicate and handling creation of their origin stories!

--

--