Skip to content

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 = "0x000000000041F3aFe8892B48D88b6862efe0ec8d"
const usdcAddress = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"
 
const privateKey =
	(process.env.PRIVATE_KEY as Hex) ??
	(() => {
		const pk = generatePrivateKey()
		writeFileSync(".env", `PRIVATE_KEY=${pk}`)
		return pk
	})()
 
const publicClient = createPublicClient({
	transport: http("https://rpc.ankr.com/eth_sepolia"),
})
 
const apiKey = "YOUR_PIMLICO_API_KEY"
const bundlerUrl = `https://api.pimlico.io/v2/sepolia/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.etherscan.io/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.etherscan.io/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 'Ethereum 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/`,
	)
}
 
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: sepolia,
	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.etherscan.io/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.etherscan.io/tx/0xf8e4fc41a134fc9530a0c019167f9dc0981874b90187717605355bdcce8b2fb7

You can now view the transaction on the 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 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!