Permissionless.js Detailed Guide
In this guide, you will learn how to sponsor the deployment of an ERC-4337 Safe account and its user operations using Pimlico (opens in a new tab) infrastructure and the permissionless (opens in a new tab) library.
This guide focuses on how user operations are built and what happens under the hood when a Safe is configured and deployed with the Safe4337Module
enabled. For a quickstart guide, check the Permissionless quickstart guide.
If you are already building with the Safe{Core} SDK, you may want to follow the Safe4337Pack guide instead of integrating the permissionless
library directly into your application.
Prerequisites
- Node.js and npm (opens in a new tab).
- A Pimlico account (opens in a new tab) and an API key.
Install dependencies
Install viem (opens in a new tab) and permissionless (opens in a new tab) dependencies by running the following command:
_10pnpm install viem permissionless
Steps
Contracts
In this guide, we will use some specific versions for the following contracts deployed on Gnosis Chain.
v0.6.0
EntryPointv1.4.1
Safe Smart Accountv0.2.0
Safe4337Modulev0.2.0
AddModuleLib
Check the commented links in the code snippet to get the correct addresses if you use a different network.
_16const ENTRYPOINT_ADDRESS_V06 = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'_16_16// https://github.com/safe-global/safe-modules-deployments/blob/main/src/assets/safe-4337-module/v0.2.0/add-modules-lib.json#L8_16const ADD_MODULE_LIB_ADDRESS = '0x8EcD4ec46D4D2a6B64fE960B3D64e8B94B2234eb'_16_16// https://github.com/safe-global/safe-modules-deployments/blob/main/src/assets/safe-4337-module/v0.2.0/safe-4337-module.json#L8_16const SAFE_4337_MODULE_ADDRESS = '0xa581c4A4DB7175302464fF3C06380BC3270b4037'_16_16// https://github.com/safe-global/safe-deployments/blob/main/src/assets/v1.4.1/safe_proxy_factory.json#L13_16const SAFE_PROXY_FACTORY_ADDRESS = '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67'_16_16// https://github.com/safe-global/safe-deployments/blob/main/src/assets/v1.4.1/safe.json#L13_16const SAFE_SINGLETON_ADDRESS = '0x41675C099F32341bf84BFc5382aF534df5C7461a'_16_16// https://github.com/safe-global/safe-deployments/blob/main/src/assets/v1.4.1/multi_send.json#L13_16const SAFE_MULTISEND_ADDRESS = '0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526'
Imports
These are all the imports required in the script we are building for this guide, which includes permissionless
and viem
packages.
_18import { bundlerActions, getAccountNonce } from 'permissionless'_18import {_18 pimlicoBundlerActions,_18 pimlicoPaymasterActions_18} from 'permissionless/actions/pimlico'_18import {_18 Address,_18 Client,_18 Hash,_18 Hex,_18 PrivateKeyAccount,_18 createClient,_18 createPublicClient,_18 encodeFunctionData,_18 http_18} from 'viem'_18import { privateKeyToAccount } from 'viem/accounts'_18import { gnosis } from 'viem/chains'
Create a signer
First, we need a signer instance that will be the owner of the Safe account once it is deployed.
_10const PRIVATE_KEY = '0x...'_10_10const signer = privateKeyToAccount(PRIVATE_KEY as Hash)
Initialize the clients
We need to create a few client instances to query the blockchain network and operate with Pimlico infrastructure.
Firstly, we instantiate a standard publicClient
instance for regular Ethereum RPC calls. To do this, we must first define the corresponding RPC URL depending on our network.
_10const rpcURL = 'https://rpc.ankr.com/gnosis'_10_10const publicClient = createPublicClient({_10 transport: http(rpcURL),_10 chain: gnosis_10})
Secondly, we instantiate the bundlerClient
using the Pimlico API v1
, which is dedicated to the Bundler methods. This API requires a PIMLICO_API_KEY
that we can get from their dashboard (opens in a new tab).
_10const PIMLICO_API_V1 = `https://api.pimlico.io/v1/gnosis/rpc?apikey=${PIMLICO_API_KEY}`_10_10const bundlerClient = createClient({_10 transport: http(PIMLICO_API_V1),_10 chain: gnosis_10})_10 .extend(bundlerActions(ENTRYPOINT_ADDRESS_V06))_10 .extend(pimlicoBundlerActions(ENTRYPOINT_ADDRESS_V06))
Lastly, we instantiate the pimlicoPaymasterClient
using the Pimlico API v2
, which is dedicated to the Paymaster methods and responsible for interacting with Pimlico's Verifying Paymaster endpoint and requesting sponsorship.
_10const PIMLICO_API_V2 = `https://api.pimlico.io/v2/gnosis/rpc?apikey=${PIMLICO_API_KEY}`_10_10const pimlicoPaymasterClient = createClient({_10 transport: http(PIMLICO_API_V2),_10 chain: gnosis_10}).extend(pimlicoPaymasterActions(ENTRYPOINT_ADDRESS_V06))
Create a UserOperation
We now define the user operation object we want to execute following the structure of the UserOperation
type.
_13type UserOperation = {_13 sender: Address_13 nonce: bigint_13 initCode: Hex_13 callData: Hex_13 callGasLimit: bigint_13 verificationGasLimit: bigint_13 preVerificationGas: bigint_13 maxFeePerGas: bigint_13 maxPriorityFeePerGas: bigint_13 paymasterAndData: Hex_13 signature: Hex_13}
We are currently missing the values for the sender
, nonce
, initCode
, and callData
properties, so we need to calculate them. The gas-related properties will be calculated later in the next step, and the signature
in the following one.
After getting these properties, we can instantiate the sponsoredUserOperation
object.
_15const contractCode = await publicClient.getBytecode({ address: sender })_15_15const sponsoredUserOperation: UserOperation = {_15 sender,_15 nonce,_15 initCode: contractCode ? '0x' : initCode,_15 callData,_15 callGasLimit: 1n, // All gas values will be filled by Estimation Response Data._15 verificationGasLimit: 1n,_15 preVerificationGas: 1n,_15 maxFeePerGas: 1n,_15 maxPriorityFeePerGas: 1n,_15 paymasterAndData: ERC20_PAYMASTER_ADDRESS,_15 signature: '0x'_15}
Get the initCode
The initCode
encodes the instructions for deploying the ERC-4337 smart account. For this reason, it's not needed when the account has already been deployed.
If we are deploying a new account, we can calculate it with the getAccountInitCode
utility function defined in the second tab, which returns the concatenation of the SafeProxyFactory
contract address and the initCodeCallData
.
The initCodeCallData
encodes the call to the createProxyWithNonce
function in the SafeProxyFactory
contract with the initializer
and a saltNonce
.
The initializer
is calculated using the getInitializerCode
function from its corresponding tab. This function returns the encoding of the call to the setup
function in the Safe contract to initialize the account with its owners
, threshold
, fallbackHandler
, etc.
In this case, we are creating a Safe account with one owner (our signer), threshold one, and the Safe4337Module
as the fallbackHandler
.
This initialization also includes the option to execute a call by using the to
and data
parameters, which we will use to enable the Safe4337Module
contract in the Safe and give an allowance to the EntryPoint
contract to pay the gas fees in an ERC-20 token like USDC. As we are performing multiple calls, we need to encode a call to the MultiSend
contract using the encodeMultiSend
function, setting the SAFE_MULTISEND_ADDRESS
as the to
and its encoding as the data
.
To enable the module in the enableModuleCallData
function, we will encode a call to the AddModuleLib
contract by passing the address of the Safe4337Module
.
_11const initCode = await getAccountInitCode({_11 owner: signer.address,_11 addModuleLibAddress: ADD_MODULE_LIB_ADDRESS,_11 safe4337ModuleAddress: SAFE_4337_MODULE_ADDRESS,_11 safeProxyFactoryAddress: SAFE_PROXY_FACTORY_ADDRESS,_11 safeSingletonAddress: SAFE_SINGLETON_ADDRESS,_11 saltNonce,_11 multiSendAddress: SAFE_MULTISEND_ADDRESS,_11 erc20TokenAddress: USDC_TOKEN_ADDRESS,_11 paymasterAddress: ERC20_PAYMASTER_ADDRESS_11})
In case of doing the token approval to the EntryPoint
contract, check the list of ERC-20 Pimlico paymasters and USDC tokens addresses (opens in a new tab) to select the correct addresses for these contracts depending on the network.
Get the Safe address
We implemented the getAccountAddress
utility function to calculate the' sender'. This function calls the viem getContractAddress
function to get the address based on:
- The
SAFE_PROXY_FACTORY_ADDRESS
- The bytecode of the deployed contract (the Safe Proxy)
- The
saltNonce
Notice that the sender
address will depend on the value of the Safe configuration properties and the saltNonce
.
_12const sender = await getAccountAddress({_12 client: publicClient,_12 owner: signer.address,_12 addModuleLibAddress: ADD_MODULE_LIB_ADDRESS,_12 safe4337ModuleAddress: SAFE_4337_MODULE_ADDRESS,_12 safeProxyFactoryAddress: SAFE_PROXY_FACTORY_ADDRESS,_12 safeSingletonAddress: SAFE_SINGLETON_ADDRESS,_12 saltNonce,_12 multiSendAddress: SAFE_MULTISEND_ADDRESS,_12 erc20TokenAddress: USDC_TOKEN_ADDRESS,_12 paymasterAddress: ERC20_PAYMASTER_ADDRESS_12})
After calculating the predicted address of the counterfactual ERC-4337 Safe account, the sender
, we can check on the Gnosis Chain block explorer (opens in a new tab) that the account is not deployed yet.
Get the nonce
To get the nonce, we can use the getAccountNonce
function.
_10const nonce = await getAccountNonce(publicClient as Client, {_10 entryPoint: ENTRYPOINT_ADDRESS_V06,_10 sender_10})
Get the callData
The callData
encodes a call to the executeUserOp
function and represents the actions that will be executed from the Safe account. In this example we are sending a transaction to the Safe account with no value and no data, resulting in an increase of the nonce of the account. However, this can be any action like a transfer of the native or an ERC-20 token, a call to another contract, etc.
Check the encodeCallData
tab to see how the encoding is implemented.
_10const callData: `0x${string}` = encodeCallData({_10 to: sender,_10 data: '0x',_10 value: 0n_10})
Estimate the UserOperation gas
To estimate the gas limits for a user operation, we call the estimateUserOperationGas
method from the bundler API, which receives the userOperation
object and entryPoint
as parameters.
After that, we call the getUserOperationGasPrice
method to get the maximum gas price and add all the returned values to the sponsoredUserOperation
.
_11const gasEstimate = await bundlerClient.estimateUserOperationGas({_11 userOperation: sponsoredUserOperation,_11 entryPoint: ENTRYPOINT_ADDRESS_V06_11})_11const maxGasPriceResult = await bundlerClient.getUserOperationGasPrice()_11_11sponsoredUserOperation.callGasLimit = gasEstimate.callGasLimit_11sponsoredUserOperation.verificationGasLimit = gasEstimate.verificationGasLimit_11sponsoredUserOperation.preVerificationGas = gasEstimate.preVerificationGas_11sponsoredUserOperation.maxFeePerGas = maxGasPriceResult.fast.maxFeePerGas_11sponsoredUserOperation.maxPriorityFeePerGas = maxGasPriceResult.fast.maxPriorityFeePerGas
To use the Paymaster to pay for the fees, we need to provide a SPONSORSHIP_POLICY_ID
that can be provided by a third party willing to sponsor our user operations, or it can be generated in the Pimlico dashboard (opens in a new tab). Sponsorship policies allow the definition of custom rules for sponsorships with various options to limit the total sponsored amount, per user, and per user operation.
On top of that, we need to overwrite some gas values from the Paymaster and add the paymasterAndData
to the sponsoredUserOperation
.
_12if (usePaymaster) {_12 const sponsorResult = await pimlicoPaymasterClient.sponsorUserOperation({_12 userOperation: sponsoredUserOperation,_12 entryPoint: ENTRYPOINT_ADDRESS_V06,_12 sponsorshipPolicyId: SPONSORSHIP_POLICY_ID_12 })_12_12 sponsoredUserOperation.callGasLimit = sponsorResult.callGasLimit_12 sponsoredUserOperation.verificationGasLimit = sponsorResult.verificationGasLimit_12 sponsoredUserOperation.preVerificationGas = sponsorResult.preVerificationGas_12 sponsoredUserOperation.paymasterAndData = sponsorResult.paymasterAndData_12}
If we don't want to use a Paymaster to pay the gas fees, we need to ensure the Safe account holds at least a few USDC tokens because the fees would be extracted from the Safe itself. Be cautious with the amount as it will depend on the callData
, and the network gasPrice
.
Sign the UserOperation
To sign the sponsoredUserOperation
, we have created the signUserOperation
utility function that returns the signature from the signer and accepts the following parameters. Check the second tab to see its implementation.
_10const chainId = 100_10_10sponsoredUserOperation.signature = await signUserOperation(_10 sponsoredUserOperation,_10 signer,_10 chainId,_10 SAFE_4337_MODULE_ADDRESS_10)
Submit the UserOperation
Call the sendUserOperation
method from the bundler to submit the sponsoredUserOperation
to the EntryPoint
contract.
_10const userOperationHash = await bundlerClient.sendUserOperation({_10 userOperation: sponsoredUserOperation,_10 entryPoint: ENTRYPOINT_ADDRESS_V06_10})
To get more details about the submitted UserOperation
object copy the value of the userOperationHash
returned, visit the UserOp Explorer (opens in a new tab), and paste it into the search bar.
Lastly, to get more details about the transaction, we can get the receipt of the sponsoredUserOperation
, get the transactionHash
, and check the transaction details in the Gnosis Chain block explorer (opens in a new tab).
_10const receipt = await bundlerClient.waitForUserOperationReceipt({_10 hash: userOperationHash_10})_10_10const transactionHash = receipt.receipt.transactionHash
Recap and further reading
This guide covered how to sponsor the deployment of a new ERC-4337 Safe and its user operations with Pimlico infrastructure using a Paymaster.
Feel free to try out other ideas and possibilities, as there are many more regarding:
- The deployment and initial setup of ERC-4337 accounts.
- The entity responsible for paying the transaction fees.
- The tokens used to pay the transaction fees.
Explore our 4337-gas-metering (opens in a new tab) repository on GitHub to see how most of these options work with Safe and notice the integrations with different providers like Alchemy, Gelato, and Pimlico (where you will find most of the code used in this guide).