Safe-Gnosis Backend Client
— Multisig Transactions from the backend
Last month, we in Think and Dev were requested to create a Safe backend to handle and administrate transactions.
Also, for the server, it would be an excellent opportunity to use the TRPC library and Zod package to build and create a typesafe API.
Using Safe Core SDK public repository, let´s put hands on!
Stack
- Node js v18
- Typescript
- TRPC
- Zod
The public repository can be found here: https://github.com/Think-and-Dev/safe-client
What we will build
At the end of this article, you will have a fully functional TRPC API which could also be used as a REST API with the following endpoints:
- [GET] /owners: Get a list of the Safe owner's addresses.
- [GET] /pending-transactions: Get a list of all Safe pending transactions.
- [GET] /transaction?hash=${txHash}: Get information about a particular transaction.
- [POST] /transaction: Propose a transaction to the Safe.
- [POST] /transaction/confirm: Confirm (from one of the Safe owners' view) a proposed transaction.
TRPC Server
First of all, we will start by setting up our TRPC server. If you have never worked with TRPC, you have to know just a few things:
- A query is equal to sending a GET request by query params.
- A mutation is equal to sending a POST request with data encoded in your body request.
- A context is like a general middleware for all your requests.
- You could define any amount of middleware functions you may like, but keep in mind that your request will go first through your context function.
Zod validations
Zod is a typescript schema validation library that could be used when we define our trcp endpoints. Defining a schema is as simple as creating a zod primitive and then just creating a zod type. For example, for our get transaction endpoint, we have to require a transaction hash that has to be a string, so we will do the following:
export const GetTransactionSchema = z.object({
hash: z.string()
})
export type GetTransactionInput = z.infer<typeof GetTransactionSchema>
And then, in our trpc server we explicitly tell the server to validate the input automatically via zod:
getTransaction: publicProcedure
.meta({
openapi: {
path: '/transaction',
method: 'GET',
description: 'Get transaction',
tags: ['SAFE'],
protect: false,
summary: 'Get transaction by tx hash'
}
})
.input(GetTransactionSchema)
......
Safe client setup
Steps:
- Ethers adapter
An
EthAdapter
, which contains all the required utilities for the SDKs to interact with the blockchain. It acts as a wrapper for web3.js or ethers.js Ethereum libraries.
return new EthersAdapter({
ethers,
signerOrProvider: safeOwner
})
}
Notice the provider we have chosen to connect with the adapter is one of the Safe owners because it will perform more than just read operations.
2. Safe API kit: For this step, you must first decide in which network your service will be running, for a full list of Safe transactions services please check here
const txServiceUrl = getTxServiceURL()
const safeService = new SafeApiKit({ txServiceUrl, ethAdapter: adapter })
3. Initialize Safe Service: Here we will be creating a SafeFactory service.
const safeFactory = await SafeFactory.create({ ethAdapter: adapter })
4. Initialize Safe SDK: For this step, you will need to provide your Safe address.
sdk = await Safe.create({
ethAdapter: this.adapter,
safeAddress: address
})
And that´s it, you are already good to go and start using Safe automatically from your backend!
Get Safe owners
const owners: string[] | undefined = await this.sdk.getOwners()
Propose transaction
For this endpoint, you will have to:
- Create a transaction: The only mandatory parameters to create a transaction are:
export interface MetaTransactionData {
readonly to: string;
readonly value: string;
readonly data: string;
}
- Get the transaction hash:
const txHash = await this.sdk.getTransactionHash(transaction)
- Sign the transaction:
const senderSignature = await this.sdk.signTransactionHash(txHash)
- Propose transaction:
await safeService.proposeTransaction({
safeAddress: this.safeAddress,
safeTransactionData: transaction.data,
safeTxHash: txHash,
senderAddress: sender,
senderSignature: senderSignature.data
})
It is important to clarify that both the sender's address and the sender's signature have to match with a valid Safe owner. Otherwise, the proposed transaction operation will fail.
See pending transactions
const pendingTxs = await safeService.getPendingTransactions(this.safeAddress!)
const processedPendingTxs = pendingTxs.results.map((pendingTx) => {
const { safe, to, value, data, safeTxHash } = pendingTx
return { safe, to, value, data, safeTxHash }
})
Get transaction info
Here you will only have to provide the hash of a valid transaction
const {
safe,
to,
value,
data,
operation,
gasToken,
safeTxGas,
baseGas,
gasPrice,
nonce,
submissionDate,
modified,
transactionHash,
safeTxHash,
isExecuted,
origin,
confirmationsRequired
} = await safeService.getTransaction(txHash)
Accept transaction
const transaction = await safeService.getTransaction(hash)
const response = await this.sdk!.executeTransaction(transaction)