Safe accounts with the Safe4337Module
EntryPoint compatibility: The Relay Kit only supports the ERC-4337 EntryPoint v0.6. v0.7 is not supported yet.
In this guide, you will learn how to create and execute multiple Safe transactions grouped in a batch from a Safe account that is not yet deployed and where the executor may or may not have funds to pay for the transaction fees. This can be achieved by supporting the ERC-4337 execution flow, which is supported by the Safe4337Module and exposed via the Relay Kit from the Safe{Core} SDK.
Read the Safe4337Module documentation to understand its benefits and flows better.
Pimlico (opens in a new tab) is used in this guide as the service provider, but any other provider compatible with the ERC-4337 can be used.
Prerequisites
- Node.js and npm (opens in a new tab).
- A Pimlico account (opens in a new tab) and an API key.
Install dependencies
_10yarn add @safe-global/relay-kit
Steps
Imports
Here are all the necessary imports for the script we implement in this guide.
_10import { Safe4337Pack } from '@safe-global/relay-kit'
Create a signer
Firstly, we need to get a signer, which will be the owner of a Safe account after it's deployed.
In this example, we use a private key, but any way to get an EIP-1193 compatible signer can be used.
_10const SIGNER_ADDRESS = // ..._10const SIGNER_PRIVATE_KEY = // ..._10const RPC_URL = 'https://rpc.ankr.com/eth_sepolia'
Initialize the Safe4337Pack
The Safe4337Pack
class is exported from the Relay Kit and implements the ERC-4337 to create, sign, and submit Safe user operations.
To instantiate this class, the static init()
method allows connecting existing Safe accounts (as long as they have the Safe4337Module
enabled) or setting a custom configuration to deploy a new Safe account at the time where the first Safe transaction is submitted.
When deploying a new Safe account, we need to pass the configuration of the Safe in the options
property. In this case, we are configuring a Safe account that will have our signer as the only owner.
_10const safe4337Pack = await Safe4337Pack.init({_10 provider: RPC_URL,_10 signer: SIGNER_PRIVATE_KEY,_10 bundlerUrl: `https://api.pimlico.io/v1/sepolia/rpc?apikey=${PIMLICO_API_KEY}`,_10 options: {_10 owners: [SIGNER_ADDRESS],_10 threshold: 1_10 },_10 // ..._10})
By default, the transaction fees will be paid in the native token and extracted from the Safe account, so there must be enough funds in the Safe address.
You can also use a paymaster to handle the fees. If you choose to use a paymaster, there are two other ways to initialize the Safe4337Pack
.
A paymaster will execute the transactions and get reimbursed from the Safe account, which must have enough funds in the Safe address in advance.
Payment of transaction fees is made using an ERC-20 token specified with the paymasterTokenAddress
property. If an ERC-20 token is used, the Safe must approve that token to the paymaster. If no balance is approved, it can be specified using the amountToApprove
property.
_10const safe4337Pack = await Safe4337Pack.init({_10 // ..._10 paymasterOptions: {_10 paymasterAddress: '0x...',_10 paymasterTokenAddress: '0x...',_10 amountToApprove // Optional_10 }_10})
Create a user operation
To create a Safe user operation, use the createTransaction()
method, which takes the array of transactions to execute and returns a SafeOperation
object.
_10// Define the transactions to execute_10const transaction1 = { to, data, value }_10const transaction2 = { to, data, value }_10_10// Build the transaction array_10const transactions = [transaction1, transaction2]_10_10// Create the SafeOperation with all the transactions_10const safeOperation = await safe4337Pack.createTransaction({ transactions })
The safeOperation
object has the data
and signatures
properties, which contain all the information about the transaction batch and the signatures of the Safe owners, respectively.
Track the user operation
Optionally, you can track all your ERC-4337 Safe transactions on-chain by attaching an on-chain identifier to the user operation callData
.
This identifier must be unique for every project and has a length of 16 bytes. You can create a random one or derive it from a text string, maybe from your project name:
_10const onchainIdentifier = toHex(_10 'TEXT_TO_DERIVE_THE_IDENTIFIER', // It could be your project name_10 { size: 16 }_10)
Once generated, fill the Ecosystem On-chain Tracking Form (opens in a new tab) and provide the value of your onchainIdentifier
.
Add the onchainIdentifier
at the end of the ERC-4337 user operation callData
.
_10safeOperation.data.callData = concat([_10 safeOperation.data.callData as `0x{string}`,_10 onchainIdentifier_10]).toString()_10_10const identifiedSafeOperation = await safe4337Pack.getEstimateFee({_10 safeOperation_10})
Once added, the Safe owners can sign the identifiedSafeOperation
.
Sign the user operation
Before sending the user operation to the bundler, it's required to sign the safeOperation
object with the connected signer. The signSafeOperation()
method, which receives a SafeOperation
object, generates a signature that will be checked when the Safe4337Module
validates the user operation.
_10const signedSafeOperation = await safe4337Pack.signSafeOperation(identifiedSafeOperation)
Submit the user operation
Once the safeOperation
object is signed, we can call the executeTransaction()
method to submit the user operation to the bundler.
_10const userOperationHash = await safe4337Pack.executeTransaction({_10 executable: signedSafeOperation_10})
This method returns the hash of the user operation. With it, we can monitor the transaction status using a block explorer or the bundler's API.
Check the transaction status
To check the transaction status, we can use the getTransactionReceipt()
method, which returns the transaction receipt after it's executed.
_10let userOperationReceipt = null_10_10while (!userOperationReceipt) {_10 // Wait 2 seconds before checking the status again_10 await new Promise((resolve) => setTimeout(resolve, 2000))_10 userOperationReceipt = await safe4337Pack.getUserOperationReceipt(_10 userOperationHash_10 )_10}
In addition, we can use the getUserOperationByHash()
method with the returned hash to retrieve the user operation object we sent to the bundler.
_10const userOperationPayload = await safe4337Pack.getUserOperationByHash(_10 userOperationHash_10)
Recap and further reading
After following this guide, we are able to deploy new Safe accounts and create, sign, and execute Safe transactions in a batch without the executor needing to have funds to pay for the transaction fees.
Learn more about the ERC-4337 standard and the Safe4337Module
contract following these links:
- ERC-4337 website (opens in a new tab)
- EIP-4337 on Ethereum EIPs (opens in a new tab)
- Safe4337Module on GitHub (opens in a new tab)
- [Safe On-chain Identifiers on GitHub](https://github.com/5afe/safe-onchain-identifiers (opens in a new tab) showcases where and how to add the identifier at the end of your Safe transactions data if you are not using the Relay Kit. Check also the specific code (opens in a new tab) where the identifier is concatenated to the
callData
.