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 accept USDC from an EOA without gas fees

This guide shows you how to accept USDC from a user's EOA, such as MetaMask or Rabby, at checkout or for deposits, without the user paying gas fees or holding ETH. The user signs a USDC permit (EIP-2612) instead of sending a transaction, and your server's smart account uses it to move the USDC, with gas sponsored by Pimlico's verifying paymaster. If you control your users' wallets, see How to send USDC without ETH for gas instead.

This guide uses Base and its native USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913). To use another chain, see Accept payments on another chain.

Prerequisites

  • A Pimlico API key on a plan that includes mainnets. Create one.
npm
npm install permissionless viem

Steps

Create the server account

On your server, create one Simple Account and a smart account client that uses Pimlico as bundler and paymaster. Every customer permits this account, so keep its owner key the same. Set MERCHANT_TREASURY to the address the USDC should move to.

import { createSmartAccountClient } from "permissionless"
import { toSimpleSmartAccount } from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import { type Address, 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 MERCHANT_TREASURY: Address = "0x1111111111111111111111111111111111111111" // where payments land
 
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 server account's owner. It never holds funds, only spends what customers permit.
const owner = privateKeyToAccount(process.env.SERVER_PRIVATE_KEY as Hex)
 
const serverAccount = await toSimpleSmartAccount({
	client: publicClient,
	owner,
	entryPoint: {
		address: entryPoint07Address,
		version: "0.7",
	},
})
 
const serverClient = createSmartAccountClient({
	account: serverAccount,
	chain: base,
	bundlerTransport: http(pimlicoUrl),
	// No paymasterContext needed: the verifying paymaster sponsors every settlement
	paymaster: pimlicoClient,
	userOperation: {
		estimateFeesPerGas: async () => {
			return (await pimlicoClient.getUserOperationGasPrice()).fast
		},
	},
})

Create the permit request

When the customer connects their wallet at checkout, build the typed data they will sign. Use the server account as the spender, the order total as the value, the customer's current nonces() value as the nonce, and the checkout expiry as the deadline.

import { parseAbi, parseUnits } from "viem"
 
const usdcAbi = parseAbi([
	"function nonces(address owner) view returns (uint256)",
	"function allowance(address owner, address spender) view returns (uint256)",
	"function balanceOf(address account) view returns (uint256)",
	"function permit(address owner, address spender, uint256 value, uint256 deadline, bytes signature)",
	"function transferFrom(address from, address to, uint256 value) returns (bool)",
])
 
export async function createPermitRequest(customer: Address, amountUsdc: string) {
	const nonce = await publicClient.readContract({
		address: USDC,
		abi: usdcAbi,
		functionName: "nonces",
		args: [customer],
	})
 
	return {
		domain: {
			name: "USD Coin", // USDC's name() on Base
			version: "2", // USDC's version() on Base
			chainId: base.id,
			verifyingContract: USDC,
		},
		types: {
			Permit: [
				{ name: "owner", type: "address" },
				{ name: "spender", type: "address" },
				{ name: "value", type: "uint256" },
				{ name: "nonce", type: "uint256" },
				{ name: "deadline", type: "uint256" },
			],
		},
		primaryType: "Permit",
		message: {
			owner: customer,
			spender: serverAccount.address,
			value: parseUnits(amountUsdc, 6),
			nonce,
			deadline: BigInt(Math.floor(Date.now() / 1000) + 30 * 60), // 30 minutes
		},
	} as const
}

Permit nonces are sequential, so settle a customer's payment before you create their next permit request.

Ask the customer to sign

Send the request to the browser and ask the customer's wallet to sign it with signTypedData. The wallet shows a USDC permit signature rather than a transaction, so it works with no ETH in the wallet.

import { createWalletClient, custom } from "viem"
 
declare const window: { ethereum: Parameters<typeof custom>[0] }
 
// In the customer's browser: any EOA wallet works, and it doesn't need ETH
const walletClient = createWalletClient({
	chain: base,
	transport: custom(window.ethereum),
})
 
const [customer] = await walletClient.requestAddresses()
 
// Fetched from your backend, which calls createPermitRequest(customer, "25")
const permitRequest = await createPermitRequest(customer, "25")
 
const signature = await walletClient.signTypedData({
	account: customer,
	...permitRequest,
})

Send the signature back to your server with the order ID.

Settle the payment

Send one user operation from the server account that calls permit with the customer's signature and then transferFrom to move the USDC to your treasury. Pimlico sponsors the gas.

import { encodeFunctionData } from "viem"
 
type PermitRequest = Awaited<ReturnType<typeof createPermitRequest>>
 
async function paymentCalls(request: PermitRequest, customerSignature: Hex) {
	const { owner: from, spender, value, deadline } = request.message
 
	const [balance, allowance] = await Promise.all([
		publicClient.readContract({ address: USDC, abi: usdcAbi, functionName: "balanceOf", args: [from] }),
		publicClient.readContract({
			address: USDC,
			abi: usdcAbi,
			functionName: "allowance",
			args: [from, spender],
		}),
	])
 
	if (balance < value) {
		throw new Error("Customer doesn't have enough USDC")
	}
 
	const pull = {
		to: USDC,
		value: 0n,
		data: encodeFunctionData({
			abi: usdcAbi,
			functionName: "transferFrom",
			args: [from, MERCHANT_TREASURY, value],
		}),
	} as const
 
	// Anyone can submit a permit. If it already landed, only pull the funds.
	if (allowance >= value) return [pull]
 
	const permit = {
		to: USDC,
		value: 0n,
		data: encodeFunctionData({
			abi: usdcAbi,
			functionName: "permit",
			args: [from, spender, value, deadline, customerSignature],
		}),
	} as const
 
	return [permit, pull]
}
 
export async function settlePayment(request: PermitRequest, customerSignature: Hex) {
	return serverClient.sendUserOperation({
		calls: await paymentCalls(request, customerSignature),
	})
}

Confirm the payment

Wait for the receipt and check success before you mark the order as paid. Retrying a failed settlement is safe: once the USDC has moved, the allowance is used up and a second transferFrom reverts.

const userOpHash = await settlePayment(permitRequest, signature)
 
const receipt = await serverClient.waitForUserOperationReceipt({ hash: userOpHash })
 
if (!receipt.success) {
	throw new Error(`Payment not settled: ${receipt.reason ?? "unknown reason"}`)
}
 
console.log(`Order paid: https://basescan.org/tx/${receipt.receipt.transactionHash}`)

Settle several payments in one user operation

To cut overhead when checkouts arrive close together, queue signed permits and settle them in a single user operation:

type SignedPayment = { request: PermitRequest; signature: Hex }
 
declare const pendingPayments: SignedPayment[]
 
// Settle several checkouts in one sponsored user operation
const calls = await Promise.all(
	pendingPayments.map(({ request, signature }) => paymentCalls(request, signature)),
)
 
const batchHash = await serverClient.sendUserOperation({ calls: calls.flat() })

If one payment in the batch fails, the whole user operation reverts. Check each permit's deadline before you add it to the batch.

Settle payments in parallel

If several settlements may be in flight at once, give each one its own nonce key so they don't wait on each other. See How to send multiple user operations in parallel. Before your first parallel settlement, deploy the server account with one user operation, as in How to send USDC payouts to thousands of recipients.

Refund a customer

To refund an order, send USDC from your treasury back to the customer. If you set MERCHANT_TREASURY to serverAccount.address, send the refund as a transfer call from the server client, and Pimlico sponsors it the same way.

Accept payments on another chain

Change the chain, the RPC URL and the USDC address. Then read name() and version() from that chain's USDC contract and use them in the typed data domain. If they don't match, the signature is invalid and settlement reverts. The same flow works for any token that implements EIP-2612 permit.

Notes

  • If the customer pays from a smart contract wallet, keep the bytes signature overload of permit used here. USDC checks it with ERC-1271, which only works once the customer's wallet is deployed.
  • If you keep the server account's owner key in a key management service (Turnkey, Fireblocks, Dfns, a cloud KMS), pass its viem LocalAccount as the owner instead of privateKeyToAccount.
  • If you don't want to host a private key on your server for this, reach out to support@pimlico.io.

Limit what you sponsor (optional)

To cap what your API key can sponsor, for example only calls to the USDC contract, create a policy in the sponsorship policies dashboard and pass its ID in the paymasterContext when you settle. See How to use sponsorship policies.

const SPONSORSHIP_POLICY_ID = "sp_your_policy_id"
 
const limitedHash = await serverClient.sendUserOperation({
	calls: await paymentCalls(permitRequest, signature),
	paymasterContext: { sponsorshipPolicyId: SPONSORSHIP_POLICY_ID },
})

Related