Tutorial 3 — Submit a user operation with an ERC-20 Paymaster
In this tutorial, you will deploy an ERC-4337 smart contract wallet on Sepolia, and submit a user operation that pays for its gas fees with USDC using an ERC-20 Paymaster.
Steps
Get a Pimlico API key
To get started, please go to our dashboard and generate a Pimlico API key.
Clone the Pimlico tutorial template repository
We have created a Pimlico tutorial template repository that you can use to get started. It comes set up with Typescript, viem, and permissionless.js.
git clone https://github.com/pimlicolabs/tutorial-template.git pimlico-tutorial-3
cd pimlico-tutorial-3
Now, let's install the dependencies:
npm install
The main file we will be working with is index.ts
. Let's run it to make sure everything is working:
npm start
If everything has been set up correctly, you should see Hello world!
printed to the console.
Create the public and bundler clients, and generate a private key
The public client will be responsible for querying the blockchain, while the bundler client will be responsible for submitting user operations for relaying.
Make sure to replace YOUR_PIMLICO_API_KEY
in the code below with your actual Pimlico API key.
Let's open up index.ts
, and add the following to the bottom:
const erc20PaymasterAddress = "0x00000000002E3A39aFEf1132214fEee5a55ce127"
const usdcAddress = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
const privateKey =
(process.env.PRIVATE_KEY as Hex) ??
(() => {
const pk = generatePrivateKey()
writeFileSync(".env", `PRIVATE_KEY=${pk}`)
return pk
})()
const publicClient = createPublicClient({
transport: http("https://sepolia.base.org"),
})
const apiKey = "YOUR_PIMLICO_API_KEY"
const bundlerUrl = `https://api.pimlico.io/v2/84532/rpc?apikey=${apiKey}`
const bundlerClient = createPimlicoBundlerClient({
transport: http(bundlerUrl),
entryPoint: ENTRYPOINT_ADDRESS_V07,
}).extend(pimlicoPaymasterActions(ENTRYPOINT_ADDRESS_V07))
Create the SmartAccount
instance
For the purposes of this guide, we will be using Safe accounts. This account is an ERC-4337 wallet controlled by a single EOA signer.
To create the Safe account, we will use the signerToSafeSmartAccount
utility function from permissionless.js.
In addition, we will also specify a call that must be executed during the deployment of the Safe account with the setupTransactions
option. Namely, we will approve unlimited USDC to the ERC-20 Paymaster to allow it to withdraw USDC from the Safe account when we use it.
Add the following to the bottom of index.ts
:
const account = await signerToSafeSmartAccount(publicClient, {
signer: privateKeyToAccount(privateKey),
entryPoint: ENTRYPOINT_ADDRESS_V07, // global entrypoint
safeVersion: "1.4.1",
setupTransactions: [
{
to: usdcAddress,
value: 0n,
data: encodeFunctionData({
abi: [parseAbiItem("function approve(address spender, uint256 amount)")],
args: [
erc20PaymasterAddress,
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn,
],
}),
},
],
})
console.log(`Smart account address: https://sepolia.basescan.org/address/${account.address}`)
Since we will be looking to fund our account with USDC (which is what we will use to pay gas fees with), we need to know the address where our smart wallet will be deployed.
Let's run this code with npm start
. You should see something like this:
Smart account address: https://sepolia.basescan.org/address/0xbAd38BdCf884ED92ab370f69C0CD0B7b8a1459A1
Get Testnet USDC on Sepolia
Let's get some USDC on the Sepolia testnet to the counterfactual address of the wallet we will be deploying. This will be used to pay for the gas fees of the user operation we will be submitting.
The recommended way to do this is to use the USDC faucet, select 'Base Sepolia' and enter the counterfactual sender address you generated in the previous step.
Verify you have USDC on the counterfactual sender address
To make sure you have USDC on the counterfactual sender address, let's add a check to the bottom of index.ts
:
const senderUsdcBalance = await publicClient.readContract({
abi: [parseAbiItem("function balanceOf(address account) returns (uint256)")],
address: usdcAddress,
functionName: "balanceOf",
args: [account.address],
})
if (senderUsdcBalance < 1_000_000n) {
throw new Error(
`insufficient USDC balance for counterfactual wallet address ${account.address}: ${
Number(senderUsdcBalance) / 1000000
} USDC, required at least 1 USDC. Load up balance at https://faucet.circle.com/ (Base Sepolia)`,
)
}
console.log(`Smart account USDC balance: ${Number(senderUsdcBalance) / 1000000} USDC`)
If you run this code with npm start
, you should not see any errors, and you should see the USDC balance of the counterfactual sender address printed to the console.
Smart account USDC balance: 10 USDC
Create the smart account client
Now that we have a SmartAccount
instance, we need to create a SmartAccountClient
instance to make transact from it. SmartAccountClient
is an almost drop-in replacement for a viem WalletClient
, but it also includes some additional functionality for interacting with smart accounts.
We then specify the optional sponsorUserOperation
middleware function. This function will set the ERC-20 Paymaster as the designated sponsor for the user operation, allowing the paymaster to pay for the gas fees of the user operation in exchange for USDC.
During the same middleware function, we will also call the the estimateUserOperationGas
function to fill in the gas limits for the user operation.
Finally, we specify the gasPrice
middleware function to fetch the gas price from the bundler that we will use to submit the user operation in the next step.
Add the following to the bottom of index.ts
:
const smartAccountClient = createSmartAccountClient({
account,
entryPoint: ENTRYPOINT_ADDRESS_V07,
chain: baseSepolia,
bundlerTransport: http(bundlerUrl),
middleware: {
gasPrice: async () => {
return (await bundlerClient.getUserOperationGasPrice()).fast
},
sponsorUserOperation: async (args) => {
const gasEstimates = await bundlerClient.estimateUserOperationGas({
userOperation: {
...args.userOperation,
paymaster: erc20PaymasterAddress,
},
})
return {
...gasEstimates,
paymaster: erc20PaymasterAddress,
}
},
},
})
Send a transaction from the smart account, paying only with USDC for gas fees.
Finally, let's submit a transaction from the smart account. We will send a transaction to the 0xd8da6bf26964af9d7eed9e03e53415d37aa96045
(vitalik.eth) address with 0x1234
as example callData
. We will also specify the gas price we want to use, which we fetched from the bundler in the previous step.
Underneath the hood, the SmartAccountClient
will build a user operation with the designated ERC-20 Paymaster, sign it with the smart account's private key, and then submit it to the bundler. The bundler will then query for receipts until it sees the user operation included on-chain.
Add the following to the bottom of index.ts
:
const txHash = await smartAccountClient.sendTransaction({
to: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
value: 0n,
data: "0x1234",
})
console.log(`User operation included: https://sepolia.basescan.org/tx/${txHash}`)
Let's run this code again with npm start
. You should see the transaction hash bundling the user operation on-chain printed to the console.
User operation included: https://sepolia.basescan.org/tx/0xf8e4fc41a134fc9530a0c019167f9dc0981874b90187717605355bdcce8b2fb7
You can now view the transaction on the Base Sepolia testnet explorer. By sending this user operation, you have:
- Deployed the counterfactual smart account contract
- Had your smart account approve the ERC-20 Paymaster to spend USDC during deployment
- Had this newly-deployed smart account verify the private key's signature
- Made an ERC-20 Paymaster sponsor the user operation's gas fees by taking USDC from the smart account
- Executed a simple transaction to
vitalik.eth
's address
If you visit the address of the sender
account on the Base Sepolia explorer, you should also see that some of your USDC balance has been deducted!
That's it! Congratulations!
Combined code
If you want to see the complete code that combines all of the previous steps, we uploaded it to a separate repository. If you're looking to run it, remember to replace the API key with your own!