How to send USDC without ETH for gas
This guide shows you how to let users send USDC from an account that holds no ETH, with either the user or your app paying the network fee. If the user's USDC is in an EOA, also follow Send from an existing EOA with EIP-7702.
This guide uses Base. For other chains, check the supported tokens. To try it on Base Sepolia first, follow Tutorial 2, which uses testnet USDC from the Circle faucet.
Installation
npm install permissionless viemYou need a Pimlico API key. Create one here.
Steps
Create the clients
The Pimlico client is both the bundler and the paymaster. Passing prepareUserOperationForErc20Paymaster as the smart account client's prepareUserOperation makes it add the paymaster approval whenever paymasterContext contains a token.
import { createSmartAccountClient } from "permissionless"
import { toSimpleSmartAccount } from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import { prepareUserOperationForErc20Paymaster } from "permissionless/experimental/pimlico"
import { createPublicClient, type Hex, http } from "viem"
import { entryPoint07Address } from "viem/account-abstraction"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" // USDC on Base
const USDC_DECIMALS = 6
const pimlicoUrl = `https://api.pimlico.io/v2/${base.id}/rpc?apikey=YOUR_PIMLICO_API_KEY`
const publicClient = createPublicClient({
chain: base,
transport: http("https://mainnet.base.org"),
})
const pimlicoClient = createPimlicoClient({
chain: base,
transport: http(pimlicoUrl),
entryPoint: {
address: entryPoint07Address,
version: "0.7",
},
})
// The user's signer. In production this is usually an embedded wallet or passkey signer.
const owner = privateKeyToAccount(process.env.PRIVATE_KEY as Hex)
const account = await toSimpleSmartAccount({
client: publicClient,
owner,
entryPoint: {
address: entryPoint07Address,
version: "0.7",
},
})
const smartAccountClient = createSmartAccountClient({
account,
chain: base,
bundlerTransport: http(pimlicoUrl),
paymaster: pimlicoClient,
userOperation: {
estimateFeesPerGas: async () => {
return (await pimlicoClient.getUserOperationGasPrice()).fast
},
// Injects the exact USDC approval for the ERC-20 Paymaster when it is needed
prepareUserOperation: prepareUserOperationForErc20Paymaster(pimlicoClient),
},
})The account address is counterfactual: it's known before the account is deployed, and the account is deployed by its first user operation. Send the user's USDC to account.address.
Show the fee before the user confirms
Prepare the user operation with the USDC transfer and paymasterContext: { token: USDC }, then convert its maximum gas cost into USDC with estimateErc20PaymasterCost. costInToken is in USDC base units (6 decimals) and costInUsd always has 6 decimals.
import { encodeFunctionData, erc20Abi, formatUnits, parseUnits } from "viem"
const recipient = "0x1111111111111111111111111111111111111111" // replace with the recipient's address
const amount = parseUnits("25", USDC_DECIMALS) // 25 USDC
const transferCall = {
to: USDC,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [recipient, amount],
}),
} as const
const userOperation = await smartAccountClient.prepareUserOperation({
calls: [transferCall],
paymasterContext: { token: USDC },
})
const { costInToken, costInUsd } = await pimlicoClient.estimateErc20PaymasterCost({
userOperation,
token: USDC,
})
console.log(`Max network fee: ${formatUnits(costInToken, USDC_DECIMALS)} USDC (~${formatUnits(costInUsd, 6)})`)The estimate is the most the paymaster can charge for this user operation. The amount actually charged is computed from the gas used, so it's usually lower.
Check the balance covers the amount and the fee
The paymaster collects USDC from the sender after the transfer executes. If the account sends its entire balance, nothing is left for the fee and the user operation fails simulation. For a "send max" button, send the balance minus the fee estimate.
const balance = await publicClient.readContract({
address: USDC,
abi: erc20Abi,
functionName: "balanceOf",
args: [account.address],
})
if (balance < amount + costInToken) {
throw new Error(
`Insufficient USDC: need ${formatUnits(amount + costInToken, USDC_DECIMALS)}, have ${formatUnits(balance, USDC_DECIMALS)}`,
)
}Send the transfer and wait for the receipt
const userOpHash = await smartAccountClient.sendUserOperation({
calls: [transferCall],
paymasterContext: { token: USDC },
})
const receipt = await smartAccountClient.waitForUserOperationReceipt({
hash: userOpHash,
})
if (!receipt.success) {
throw new Error(`User operation reverted: ${receipt.reason ?? "unknown reason"}`)
}
console.log(`Sent 25 USDC: https://basescan.org/tx/${receipt.receipt.transactionHash}`)
console.log(`Fee paid in USDC, actual gas cost: ${receipt.actualGasCost} wei`)Check receipt.success before you show the transfer as sent. If the account's allowance for the paymaster is too low, the user operation approves the maximum fee before the transfer, in the same batch, so the user still signs once.
Send from an existing EOA with EIP-7702
If the user's USDC is in an EOA, delegate the EOA to a Simple account implementation with EIP-7702 instead of moving funds to a new address. The address stays the same; the account uses EntryPoint 0.8, so the Pimlico client is configured for 0.8 too.
import { createSmartAccountClient } from "permissionless"
import { to7702SimpleSmartAccount } from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import { prepareUserOperationForErc20Paymaster } from "permissionless/experimental/pimlico"
import { createPublicClient, encodeFunctionData, erc20Abi, type Hex, http, parseUnits } from "viem"
import { entryPoint08Address } from "viem/account-abstraction"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" // USDC on Base
const SIMPLE_7702_IMPLEMENTATION = "0xe6Cae83BdE06E4c305530e199D7217f42808555B"
const pimlicoUrl = `https://api.pimlico.io/v2/${base.id}/rpc?apikey=YOUR_PIMLICO_API_KEY`
const publicClient = createPublicClient({
chain: base,
transport: http("https://mainnet.base.org"),
})
const pimlicoClient = createPimlicoClient({
chain: base,
transport: http(pimlicoUrl),
entryPoint: {
address: entryPoint08Address,
version: "0.8",
},
})
// The user's existing EOA. It must be a signer that can sign EIP-7702 authorizations.
const eoa = privateKeyToAccount(process.env.EOA_PRIVATE_KEY as Hex)
// The smart account address is the EOA's own address
const account = await to7702SimpleSmartAccount({
client: publicClient,
owner: eoa,
})
const smartAccountClient = createSmartAccountClient({
account,
chain: base,
bundlerTransport: http(pimlicoUrl),
paymaster: pimlicoClient,
userOperation: {
estimateFeesPerGas: async () => {
return (await pimlicoClient.getUserOperationGasPrice()).fast
},
prepareUserOperation: prepareUserOperationForErc20Paymaster(pimlicoClient),
},
})Sign an EIP-7702 authorization and attach it to the first user operation only. Once the EOA is delegated, account.isDeployed() returns true and you can omit it.
const transferCall = {
to: USDC,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0x1111111111111111111111111111111111111111", parseUnits("25", 6)],
}),
} as const
// The EOA only has code once its EIP-7702 delegation has landed
const isDelegated = await account.isDeployed()
const txHash = await smartAccountClient.sendTransaction({
calls: [transferCall],
paymasterContext: { token: USDC },
// Only the first user operation needs to carry the signed authorization
authorization: isDelegated
? undefined
: await eoa.signAuthorization({
address: SIMPLE_7702_IMPLEMENTATION,
chainId: base.id,
nonce: await publicClient.getTransactionCount({ address: eoa.address }),
}),
})
console.log(`https://basescan.org/tx/${txHash}`)The signer has to support signing EIP-7702 authorizations. viem local accounts can; for an embedded wallet, check its provider's documentation. For browser extension wallets, see external wallets. The EIP-7702 quickstart walks through the same flow on Sepolia.
Notes
- Approval strategy:
prepareUserOperationForErc20Paymasterapproves only the maximum fee of each user operation. If you prefer a one-time unlimited approval, see using the ERC-20 Paymaster with unlimited approvals. - Sponsorship window: an ERC-20 Paymaster sponsorship is valid for 10 minutes, so prepare and send the user operation in the same session.
- API keys: in a client app, route Pimlico requests through your backend instead of embedding the key (guide).