Integrate Gnosis Safe into your React Web3 App
Victoria
Posted on April 23, 2024
Recently, I had a chance to work on an interesting task where I needed to integrate the Gnosis Safe multi-signature wallet into the existing application.
I will walk you through a short tutorial, and I hope it will help you save a few hours of development. Let's begin.
First of all, we should start with the official documentation for their API SDK.
You can also explore what Gnosis Safe provides for developers, but in the context of our tutorial, we are going to keep it simple.
You need to have already created a Gnosis Safe wallet with several addresses added as signers. The process is quite simple, so we also won't focus on this.
According to the documentation, the first step is to install an SDK. I will use yarn, but feel free to use any other package manager.
yarn add @safe-global/api-kit
Next, we need to create an EthAdapter based on the library you are using in your app. I will use ethers.js 6.12
Depending on the library used by the dapp, there are two options:
Once the instance of EthersAdapter or Web3Adapter is created, it can be used in the initialization of the API Kit.
Here is the trick. The documentation tells you to use a private key to instantiate a wallet, but you can just use a signer from the provider.
I created a TS file in a 'gnosis' folder, called it 'adapters.ts':
let signer = null;
let provider;
if (window.ethereum == null) {
// If MetaMask is not installed, we use the default provider,
// which is backed by a variety of third-party services (such
// as INFURA). They do not have private keys installed,
// so they only have read-only access
console.log("MetaMask not installed; using read-only defaults");
provider = ethers.getDefaultProvider();
} else {
// Connect to the MetaMask EIP-1193 object. This is a standard
// protocol that allows Ethers access to make all read-only
// requests through MetaMask.
provider = new ethers.BrowserProvider(window.ethereum);
// It also provides an opportunity to request access to write
// operations, which will be performed by the private key
// that MetaMask manages for the user.
signer = await provider.getSigner();
}
export const ethAdapter = new EthersAdapter({
ethers,
signerOrProvider: signer || provider,
});
Here you might encounter an error from Webpack about global await, just enable this experimental feature in your webpack config. You can use react-app-rewired to adjust your webpack configuration.
After we have created ethAdapter, we can proceed and create the apiKit instance, which is literally a few lines of code:
export const apiKit = new SafeApiKit({
chainId: ethers.toBigInt(process.env.REACT_APP_CHAIN_ID as string),
});
Full file for this:
import { EthersAdapter } from "@safe-global/protocol-kit";
import SafeApiKit from "@safe-global/api-kit";
import { ethers } from "ethers";
let signer = null;
let provider;
if (window.ethereum == null) {
// If MetaMask is not installed, we use the default provider,
// which is backed by a variety of third-party services (such
// as INFURA). They do not have private keys installed,
// so they only have read-only access
console.log("MetaMask not installed; using read-only defaults");
provider = ethers.getDefaultProvider();
} else {
// Connect to the MetaMask EIP-1193 object. This is a standard
// protocol that allows Ethers access to make all read-only
// requests through MetaMask.
provider = new ethers.BrowserProvider(window.ethereum);
// It also provides an opportunity to request access to write
// operations, which will be performed by the private key
// that MetaMask manages for the user.
signer = await provider.getSigner();
}
export const ethAdapter = new EthersAdapter({
ethers,
signerOrProvider: signer || provider,
});
export const apiKit = new SafeApiKit({
chainId: ethers.toBigInt(process.env.REACT_APP_CHAIN_ID as string),
});
And now we can start building our transaction factory. I created a separate file called 'transactions.ts':
We need to propose a transaction to service and need to start from instantiating a protocolKit:
const protocolKit = await Safe.create({
ethAdapter,
safeAddress: process.env.REACT_APP_GNOSIS_SAFE_ADDRESS as string,
});
Then we create a reusable function to prepare a transaction:
function createSafeTransaction(to: string, value: string, data: string) {
return protocolKit.createTransaction({
transactions: [
{
to,
value,
data,
},
],
});
}
UPD: If you are facing an issue with a conflicting nonce
, try to add the following by utilizing API Kit:
const nextNonce = await apiKit.getNextNonce(process.env.REACT_APP_GNOSIS_SAFE_ADDRESS)
Then include it as an option:
return protocolKit.createTransaction({
transactions: [
{
to,
value,
data,
},
],
options: {
nonce: nextNonce,
},
});
Next step we create a function for a proposal:
async function proposeTransaction(safeTransaction: SafeTransaction, signer: ethers.Signer) {
console.log("Proposing transaction");
const senderAddress = await signer.getAddress();
const safeTxHash = await protocolKit.getTransactionHash(safeTransaction);
const signature = await protocolKit.signHash(safeTxHash);
return await apiKit.proposeTransaction({
safeAddress: await protocolKit.getAddress(),
safeTransactionData: safeTransaction.data,
safeTxHash,
senderAddress,
senderSignature: signature.data,
});
}
And finally, the transaction itself. Pay attention here, that we need to encode a transaction function call with the right params. If you use ethers.js, you can check out documentation explaining how it works:
async function safeBorrowRegionalPool(amount: string, signer: ethers.Signer, poolId: string, decimals: number = 6) {
const CONTRACT_POOL_FACTORY = process.env.REACT_APP_CONTRACT_POOL_FACTORY!;
const poolFactory = new ethers.Contract(CONTRACT_POOL_FACTORY, abiPoolFactory, signer);
let poolAddress = await poolFactory.pools(poolId);
const abi_interface = new ethers.Interface(abiRegionalPool);
const parsedAmount = ethers.parseUnits(amount, decimals);
const signerAddress = await signer.getAddress();
const encodedFunctionCall = abi_interface.encodeFunctionData("borrow", [signerAddress, parsedAmount]);
const safeTransaction = await createSafeTransaction(poolAddress, "0", encodedFunctionCall);
return await proposeTransaction(safeTransaction, signer);
}
Now we can finally call the function!
const signer = provider?.getSigner();
safeBorrowRegionalPool(drawdownAmount.toString(), signer,
poolId as string, decimals).then((res) => {
console.log("Gnosis Safe Transaction created successfully!");
});
So what is happening when we call it? Let's see...
Metamask asks to confirm the transaction (our proposal):
Now we can see that the transaction appeared in the wallet's queue:
You can go even further and retrieve pending transactions from SDK, and confirm it:
const safeTxHash = transaction.transactionHash
const signature = await protocolKit.signHash(safeTxHash)
// Confirm the Safe transaction
const signatureResponse = await apiKit.confirmTransaction(safeTxHash, signature.data)
You can explore full references for all available methods here.
That's all.
Now your web3 application is type-safe and protected by a multi-signature wallet.
Happy coding!
Posted on April 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.