Tutorials
Tutorial 2 — Submit a User Operation with an ERC-20 Paymaster

Tutorial 2 — Submit a User Operation with an ERC-20 Paymaster

You can visit our ERC-20 Paymaster overview page to learn more about the design and architecture of our ERC-20 Paymaster, the Typescript SDK documentation, and the deployed smart contract addresses.

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.

Get a Pimlico API key

The ERC-20 paymaster itself is completely permissionless, there is no requirement to use an API key for it. We are using an API key in this tutorial to get access to the bundler and verifying paymaster to make the tutorial flow easier.

To get started, please go to our dashboard (opens in a new tab) and generate a Pimlico API key.

Clone the Pimlico tutorial template repository

We have created a Pimlico tutorial template repository (opens in a new tab) that you can use to get started. It comes set up with Typescript, ethers v5, and a few other useful packages.

git clone https://github.com/pimlicolabs/tutorial-template.git pimlico-tutorial-2
cd pimlico-tutorial-2

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 generate this with the box below, or by calling generatePrivateKey().

⚠️

Do not use this private key in production.

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:

// DEFINE THE CONSTANTS
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:

// CALCULATE THE DETERMINISTIC SENDER ADDRESS
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 (opens in a new tab), 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 (opens in a new tab) and then visit this page to swap some of the testnet MATIC to testnet USDC on Uniswap (opens in a new tab) 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 (opens in a new tab) 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:

// DEPLOY THE SIMPLE WALLET
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 = "0x32aCDFeA07a614E52403d2c1feB747aa8079A353"
const usdcTokenAddress = "0x0FA8781a83E46826621b3BC094Ea2A0212e71B23" // 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,
    address: 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 (opens in a new tab), 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 @pimlico/erc20-paymaster SDK to verify that enough tokens are approved to the paymaster and 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:

// SPONSOR A USER OPERATION WITH THE ERC-20 PAYMASTER
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,
    address: 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 (opens in a new tab), 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 (opens in a new tab). If you're looking to run it, remember to replace the API key with your own and to use the USDC Faucet (opens in a new tab) to fill your counterfactual sender address with some USDC after you calculate it.