Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

How to send USDC remittances without ETH for gas

This guide shows you how to let remittance app users send USDC across borders without holding ETH: your app sponsors the network fee through the verifying paymaster, and if sponsorship is declined the user pays it in USDC through the ERC-20 Paymaster. If your users keep USDC in an EOA, also follow Send from an existing EOA with EIP-7702.

This guide uses Base and its native USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913). The same code works on any supported chain: change the chain, the RPC URL and the USDC address, and check the ERC-20 Paymaster's supported tokens.

Installation

npm
npm install permissionless viem

You also need a Pimlico API key. Create one here.

Steps

Create the clients

Create a public client, a Pimlico client (used as both bundler and paymaster), the user's smart account, and a smart account client. One client handles both payment modes: the prepareUserOperationForErc20Paymaster override only acts when the 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 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",
	},
})
 
// Replace with the user's signer (embedded wallet, passkey, etc.)
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
		},
		// Only changes behaviour when paymasterContext contains a `token`
		prepareUserOperation: prepareUserOperationForErc20Paymaster(pimlicoClient),
	},
})

Sponsor the transfer, with USDC as the fallback

Send the transfer without a paymasterContext so the verifying paymaster sponsors it from your Pimlico balance. If sponsorship is declined, the request fails: catch it and resend with paymasterContext: { token: USDC } so the ERC-20 Paymaster takes the fee from the user's USDC.

import { type Address, encodeFunctionData, erc20Abi, parseUnits } from "viem"
 
export async function sendRemittance(recipient: Address, amountUsdc: string) {
	const calls = [
		{
			to: USDC,
			value: 0n,
			data: encodeFunctionData({
				abi: erc20Abi,
				functionName: "transfer",
				args: [recipient, parseUnits(amountUsdc, 6)],
			}),
		},
	] as const
 
	try {
		// 1. No paymasterContext: the verifying paymaster sponsors the transfer
		//    and charges your Pimlico balance
		const hash = await smartAccountClient.sendUserOperation({ calls })
		return { hash, feePaidBy: "app" as const }
	} catch (error) {
		// 2. Sponsorship was declined: let the ERC-20 Paymaster take the
		//    network fee in USDC instead
		console.warn("Sponsorship declined, falling back to USDC gas", error)
		const hash = await smartAccountClient.sendUserOperation({
			calls,
			paymasterContext: { token: USDC },
		})
		return { hash, feePaidBy: "user" as const }
	}
}

In the fallback path, permissionless.js adds an approve call for the ERC-20 Paymaster in front of the transfer when the existing allowance is too low, so the user still signs one user operation. If you want to show the fee before the user confirms, use estimateErc20PaymasterCost as shown in How to calculate a user operation's cost.

Wait for the receipt

sendUserOperation returns a user operation hash as soon as the bundler accepts it. Wait for the receipt before you show the transfer as delivered, and check success: a user operation can be included on chain and still revert.

const { hash } = await sendRemittance("0x1111111111111111111111111111111111111111", "50")
 
const receipt = await smartAccountClient.waitForUserOperationReceipt({ hash })
 
if (!receipt.success) {
	throw new Error(`Transfer reverted: ${receipt.reason ?? "unknown reason"}`)
}
 
console.log(`Delivered: https://basescan.org/tx/${receipt.receipt.transactionHash}`)

If a receipt takes longer than expected, pimlico_getUserOperationStatus tells you whether the operation is still in the mempool, submitted, included, or was rejected.

Send from an existing EOA with EIP-7702

If your users already hold USDC in an EOA, you don't have to move their funds to a new address. With EIP-7702 the EOA delegates to a smart account implementation and keeps its address. Swap toSimpleSmartAccount for to7702SimpleSmartAccount (EntryPoint 0.8) and attach a signed authorization to the first user operation:

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),
	},
})
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 must be able to sign EIP-7702 authorizations: viem local accounts can, and for an embedded wallet check its provider's documentation. Browser extension wallets generally don't let apps request an authorization; for those, see sponsoring transactions for external wallets. Check that your chain supports EIP-7702 on the supported chains page (Base does), and see the EIP-7702 quickstart for the full flow.

Limit sponsored transfers per user (optional)

To sponsor only a few transfers per user and charge the rest in USDC, cap your spend with a sponsorship policy. In the dashboard, create a policy for Base with a per-user daily limit, for example 5 user operations, and copy its ID (sp_…). Then pass it in the sponsored attempt of sendRemittance:

const SPONSORSHIP_POLICY_ID = "sp_your_policy_id"
 
// In sendRemittance, replace the sponsored attempt with:
const hash = await smartAccountClient.sendUserOperation({
	calls,
	paymasterContext: { sponsorshipPolicyId: SPONSORSHIP_POLICY_ID },
})

Once a user reaches the limit, sponsorship is declined and the transfer falls back to USDC. If you send on several chains, allow each chain in the policy. For more controls, such as sponsoring only your own customers, see sponsorship policies.

Notes

  • Funding sponsorship: keep your Pimlico balance (or attached card) able to cover the maximum cost of every sponsored user operation in flight. Pimlico holds each one's maximum possible cost and refunds the difference after it lands (verifying paymaster FAQ).
  • Sponsorship validity: if users may sign and submit later than 10 minutes after sponsorship, extend it with validForSeconds (guide). ERC-20 Paymaster sponsorships are always valid for 10 minutes, so send fallback transfers in the same session.
  • Fiat and cross-chain: to convert fiat to and from stablecoins or move funds between chains, use an on/off-ramp or bridge provider. Pimlico only covers gas and transaction inclusion.

Related