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 Mumbai, and submit a User Operation that pays for its gas fees with USDC using Pimlico's 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.

Define the constants we'll need

Let's open up index.ts.

First, let's define some of the constants we'll need to use to make the flow work.

Before going any further, let's replace privateKey with a new private key. You can use the randomly generated one below, or by calling generatePrivateKey().

Private key:
Address:

Remember to replace the apiKey variable with your own Pimlico API key. This will allow you to use our bundler to submit your User Operation.

After replacing these two variables, add the following to the bottom of index.ts:

const privateKey = "GENERATED_PRIVATE_KEY" // replace this with a private key you generate!
const apiKey = "YOUR_PIMICO_API_KEY" // replace with your Pimlico API key
 
const ENTRY_POINT_ADDRESS = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
const SIMPLE_ACCOUNT_FACTORY_ADDRESS = "0x9406Cc6185a346906296840746125a0E44976454"
 
const chain = "mumbai"
 
if (apiKey === undefined) {
	throw new Error("Please replace the `apiKey` env variable with your Pimlico API key")
}
 
if (privateKey.match(/GENERATED_PRIVATE_KEY/)) {
	throw new Error(
		"Please replace the `privateKey` variable with a newly generated private key. You can use `generatePrivateKey()` for this",
	)
}
 
const signer = privateKeyToAccount(privateKey as Hash)
 
const bundlerClient = createClient({
	transport: http(`https://api.pimlico.io/v1/${chain}/rpc?apikey=${apiKey}`),
	chain: polygonMumbai,
})
	.extend(bundlerActions)
	.extend(pimlicoBundlerActions)
 
const paymasterClient = createClient({
	// ⚠️ using v2 of the API ⚠️
	transport: http(`https://api.pimlico.io/v2/${chain}/rpc?apikey=${apiKey}`),
	chain: polygonMumbai,
}).extend(pimlicoPaymasterActions)
 
const publicClient = createPublicClient({
	transport: http("https://mumbai.rpc.thirdweb.com"),
	chain: polygonMumbai,
})

Calculate the deterministic address of your smart wallet

Since we will be looking to fund our account with USDC (that we will use to sponsor UserOperations with), we need to know the address where our smart wallet will be deployed.

Add the following to the bottom of index.ts:

const initCode = concat([
	SIMPLE_ACCOUNT_FACTORY_ADDRESS,
	encodeFunctionData({
		abi: [
			{
				inputs: [
					{ name: "owner", type: "address" },
					{ name: "salt", type: "uint256" },
				],
				name: "createAccount",
				outputs: [{ name: "ret", type: "address" }],
				stateMutability: "nonpayable",
				type: "function",
			},
		],
		args: [signer.address, 0n],
	}),
])
 
const senderAddress = await getSenderAddress(publicClient, {
	initCode,
	entryPoint: ENTRY_POINT_ADDRESS,
})
console.log("Counterfactual sender address:", senderAddress)

Let's run this code with npm start. You should see something like this:

Counterfactual sender address: 0xbAd38BdCf884ED92ab370f69C0CD0B7b8a1459A1

Get Testnet USDC on Mumbai

Let's get some USDC on the Mumbai 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 Operations we will be submitting.

The recommended way to do this is to use the USDC faucet, select POLYGON and enter the counterfactual sender address you generated in the previous step.

Alternatively, if the above step does not work, you can get some testnet MATIC. Then, if you're using Metamask, switch your chain to Mumbai and then visit this page to swap some of the testnet MATIC to testnet USDC on Uniswap and then send that USDC to the counterfactual sender address.

Deploy a SimpleWallet

In our first tutorial we went through an example flow where we deployed a SimpleWallet using Pimlico's verifying paymaster and bundler. We will do something similar in this guide, except that we will be mainly looking to leverage the ERC-20 Paymaster.

During deployment, let's send an approval transaction that will approve unlimited USDC tokens to the ERC-20 Paymaster. This saves us an extra step and allows us to already start using the ERC-20 Paymaster starting from the next User Operation.

Add the following to the bottom of index.ts:

const genereteApproveCallData = (erc20TokenAddress: Address, paymasterAddress: Address) => {
	const approveData = encodeFunctionData({
		abi: [
			{
				inputs: [
					{ name: "_spender", type: "address" },
					{ name: "_value", type: "uint256" },
				],
				name: "approve",
				outputs: [{ name: "", type: "bool" }],
				payable: false,
				stateMutability: "nonpayable",
				type: "function",
			},
		],
		args: [paymasterAddress, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn],
	})
 
	// GENERATE THE CALLDATA TO APPROVE THE USDC
	const to = erc20TokenAddress
	const value = 0n
	const data = approveData
 
	const callData = encodeFunctionData({
		abi: [
			{
				inputs: [
					{ name: "dest", type: "address" },
					{ name: "value", type: "uint256" },
					{ name: "func", type: "bytes" },
				],
				name: "execute",
				outputs: [],
				stateMutability: "nonpayable",
				type: "function",
			},
		],
		args: [to, value, data],
	})
 
	return callData
}
 
const submitUserOperation = async (userOperation: UserOperation) => {
	const userOperationHash = await bundlerClient.sendUserOperation({
		userOperation,
		entryPoint: ENTRY_POINT_ADDRESS,
	})
	console.log(`UserOperation submitted. Hash: ${userOperationHash}`)
 
	console.log("Querying for receipts...")
	const receipt = await bundlerClient.waitForUserOperationReceipt({
		hash: userOperationHash,
	})
	console.log(`Receipt found!\nTransaction hash: ${receipt.receipt.transactionHash}`)
}
 
// You can get the paymaster addresses from https://docs.pimlico.io/reference/erc20-paymaster/contracts
const erc20PaymasterAddress = "0x000000000009B901DeC1aaB9389285965F49D387"
const usdcTokenAddress = "0x9999f7Fea5938fD3b1E26A12c3f2fb024e194f97" // USDC on Polygon Mumbai
 
const senderUsdcBalance = await publicClient.readContract({
	abi: [
		{
			inputs: [{ name: "_owner", type: "address" }],
			name: "balanceOf",
			outputs: [{ name: "balance", type: "uint256" }],
			type: "function",
			stateMutability: "view",
		},
	],
	address: usdcTokenAddress,
	functionName: "balanceOf",
	args: [senderAddress],
})
 
if (senderUsdcBalance < 1_000_000n) {
	throw new Error(
		`insufficient USDC balance for counterfactual wallet address ${senderAddress}: ${
			Number(senderUsdcBalance) / 1000000
		} USDC, required at least 1 USDC`,
	)
}
 
const approveCallData = genereteApproveCallData(usdcTokenAddress, erc20PaymasterAddress)
 
// FILL OUT THE REMAINING USEROPERATION VALUES
const gasPriceResult = await bundlerClient.getUserOperationGasPrice()
 
const userOperation: Partial<UserOperation> = {
	sender: senderAddress,
	nonce: 0n,
	initCode,
	callData: approveCallData,
	maxFeePerGas: gasPriceResult.fast.maxFeePerGas,
	maxPriorityFeePerGas: gasPriceResult.fast.maxPriorityFeePerGas,
	paymasterAndData: "0x",
	signature:
		"0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c",
}
 
const nonce = await getAccountNonce(publicClient, {
	entryPoint: ENTRY_POINT_ADDRESS,
	sender: senderAddress,
})
 
if (nonce === 0n) {
	// SPONSOR THE USEROPERATION USING THE VERIFYING PAYMASTER
	const result = await paymasterClient.sponsorUserOperation({
		userOperation: userOperation as UserOperation,
		entryPoint: ENTRY_POINT_ADDRESS,
	})
 
	userOperation.preVerificationGas = result.preVerificationGas
	userOperation.verificationGasLimit = result.verificationGasLimit
	userOperation.callGasLimit = result.callGasLimit
	userOperation.paymasterAndData = result.paymasterAndData
 
	// SIGN THE USEROPERATION
	const signature = await signUserOperationHashWithECDSA({
		account: signer,
		userOperation: userOperation as UserOperation,
		chainId: polygonMumbai.id,
		entryPoint: ENTRY_POINT_ADDRESS,
	})
 
	userOperation.signature = signature
	await submitUserOperation(userOperation as UserOperation)
} else {
	console.log("Deployment UserOperation previously submitted, skipping...")
}

Let's run this code with npm start. The wallet should get deployed and you should see something like this:

Receipt not found...
Receipt not found...
Receipt found!
Transaction hash: 0x23d480c51578d78a15a1fd3fcc0c0aa1658eb27a9edc36ce442e1b79363a74ba

Now, if you go to the sender variable's address on Mumbai Scan, you should see a contract deployed there!

Send a User Operation, paying only with USDC using the ERC-20 Paymaster

Now that we have a smart wallet deployed with USDC approved to the ERC-20 Paymaster address, let's submit another User Operation. but paying solely with USDC this time!

For this step, we'll leverage the permissionless.js to generate the paymasterAndData that will ask Pimlico's ERC-20 paymaster to sponsor the User Operation in exchange for USDC.

Add the following to the bottom of index.ts:

const genereteDummyCallData = () => {
	// SEND EMPTY CALL TO VITALIK
	const to = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" // vitalik
	const value = 0n
	const data = "0x"
 
	const callData = encodeFunctionData({
		abi: [
			{
				inputs: [
					{ name: "dest", type: "address" },
					{ name: "value", type: "uint256" },
					{ name: "func", type: "bytes" },
				],
				name: "execute",
				outputs: [],
				stateMutability: "nonpayable",
				type: "function",
			},
		],
		args: [to, value, data],
	})
 
	return callData
}
 
console.log("Sponsoring a user operation with the ERC-20 paymaster...")
 
const newNonce = await getAccountNonce(publicClient, {
	entryPoint: ENTRY_POINT_ADDRESS,
	sender: senderAddress,
})
 
const sponsoredUserOperation: UserOperation = {
	sender: senderAddress,
	nonce: newNonce,
	initCode: "0x",
	callData: genereteDummyCallData(),
	callGasLimit: 100_000n, // hardcode it for now at a high value
	verificationGasLimit: 500_000n, // hardcode it for now at a high value
	preVerificationGas: 50_000n, // hardcode it for now at a high value
	maxFeePerGas: gasPriceResult.fast.maxFeePerGas,
	maxPriorityFeePerGas: gasPriceResult.fast.maxPriorityFeePerGas,
	paymasterAndData: erc20PaymasterAddress, // to use the erc20 paymaster, put its address in the paymasterAndData field
	signature: "0x",
}
 
// SIGN THE USEROPERATION
 
sponsoredUserOperation.signature = await signUserOperationHashWithECDSA({
	account: signer,
	userOperation: sponsoredUserOperation,
	chainId: polygonMumbai.id,
	entryPoint: ENTRY_POINT_ADDRESS,
})
 
await submitUserOperation(sponsoredUserOperation)

If we run this code with npm start, a User Operation will be submitted using the ERC-20 Paymaster. Since we approved USDC to the paymaster in the previous step, the paymaster will be able to withdraw the required amount of USDC from your wallet to fund the gas cost. You should see something like this:

Sponsoring a user operation with the ERC-20 paymaster...
Receipt not found...
Receipt not found...
Receipt found!
Transaction hash: 0x23d480c51578d78a15a1fd3fcc0c0aa1658eb27a9edc36ce442e1b79363a74ba

If you visit the address of the sender account on Mumbai Scan, you should also see that some of your USDC balance has been deducted!

That's it!

Congratulations, you have submitted your first User Operation using Pimlico's ERC-20 Paymaster! From now on, you will no longer need to keep native gas tokens like ETH and MATIC on your wallet to be able to transact. 🎉

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 and to use the USDC Faucet to fill your counterfactual sender address with some USDC after you calculate it.