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 payouts to thousands of recipients

This guide shows you how to pay thousands of recipients in USDC from a smart account treasury, by batching transfer calls into ERC-4337 user operations and sending them in parallel, with gas sponsored by the verifying paymaster or paid from the treasury's USDC by the ERC-20 Paymaster. Batching removes the per-transaction overhead, but every transfer still costs its own execution gas, so measure what a chunk costs on your chain instead of assuming a price.

Installation

npm
npm install permissionless viem

Steps

Create the treasury client

This example uses a Safe as the treasury on Base, with EntryPoint 0.7. Keep the treasury key in a signer service such as Turnkey, Fireblocks or Dfns, not in an environment variable. For a treasury that needs several approvers, see Safe with multiple signers.

By default the verifying paymaster sponsors the gas from your Pimlico balance. To pay it from the treasury's USDC through the ERC-20 Paymaster instead, set paymasterContext: { token: USDC } on the client; the prepareUserOperationForErc20Paymaster override only acts in that case.

import { createSmartAccountClient } from "permissionless"
import { toSafeSmartAccount } from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import { prepareUserOperationForErc20Paymaster } from "permissionless/experimental/pimlico"
import { createPublicClient, type Hex, http, type LocalAccount } from "viem"
import { entryPoint07Address } from "viem/account-abstraction"
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",
	},
})
 
// Your treasury signer: a Turnkey, Fireblocks, Dfns or KMS-backed account
declare const treasurySigner: LocalAccount
 
const treasury = await toSafeSmartAccount({
	client: publicClient,
	owners: [treasurySigner],
	entryPoint: {
		address: entryPoint07Address,
		version: "0.7",
	},
	version: "1.4.1",
})
 
const treasuryClient = createSmartAccountClient({
	account: treasury,
	chain: base,
	bundlerTransport: http(pimlicoUrl),
	paymaster: pimlicoClient,
	// Gas is sponsored from your Pimlico balance. To pay it from the treasury's USDC instead,
	// set `paymasterContext: { token: USDC }`
	userOperation: {
		estimateFeesPerGas: async () => {
			return (await pimlicoClient.getUserOperationGasPrice()).fast
		},
		prepareUserOperation: prepareUserOperationForErc20Paymaster(pimlicoClient),
	},
})

To cap what a bug in your payout job can spend, pass a sponsorshipPolicyId with a global limit (sponsorship policies). If you sponsor, keep your Pimlico balance above the maximum cost of all chunks in flight: Pimlico holds each sponsored user operation's maximum possible cost until it lands, then refunds the difference (FAQ).

Deploy the treasury before sending in parallel

Send one user operation from the treasury on its own and wait for its receipt before you send any chunks. Parallel user operations only work once the account is deployed: if several of them carry the account's factory data, they conflict and fail with AA10. The send code below checks treasury.isDeployed() and stops if it isn't.

Turn payouts into transfer calls and chunks

Validate recipients before you chunk them. If one transfer reverts, for example to a USDC-blacklisted address or because the balance runs out, the whole user operation reverts and none of that chunk's transfers happen.

import { type Address, encodeFunctionData, erc20Abi } from "viem"
 
type Payout = { recipient: Address; amount: bigint } // amount in USDC base units (6 decimals)
 
function toTransferCall({ recipient, amount }: Payout) {
	return {
		to: USDC as Address,
		value: 0n,
		data: encodeFunctionData({
			abi: erc20Abi,
			functionName: "transfer",
			args: [recipient, amount],
		}),
	}
}
 
function chunk<T>(items: T[], size: number): T[][] {
	const chunks: T[][] = []
	for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size))
	return chunks
}

Measure a chunk before choosing its size

Prepare (don't send) a user operation for a sample chunk. prepareUserOperation runs gas estimation against the current chain state, so the treasury must hold enough USDC for the sample transfers. getRequiredPrefund gives the maximum the user operation can cost at the current gas price.

import { getRequiredPrefund } from "permissionless/utils"
 
declare const payouts: Payout[] // loaded from your database
 
const sample = await treasuryClient.prepareUserOperation({
	calls: payouts.slice(0, 50).map(toTransferCall),
})
 
console.log({
	callGasLimit: sample.callGasLimit,
	callGasPerTransfer: sample.callGasLimit / 50n,
	// Upper bound in wei for this user operation at the current gas price
	maxCostWei: getRequiredPrefund({ userOperation: sample, entryPointVersion: "0.7" }),
})

Transfers to addresses that have never held USDC usually cost more gas than transfers to existing holders, because a new storage slot is written. Measure with a realistic mix of recipients, then pick a chunk size that leaves plenty of headroom: a user operation runs inside a single bundle transaction, so it must fit within what the chain allows for one transaction and what the bundler accepts.

Give every chunk a deterministic nonce

Derive the nonce key from the payout run ID and the chunk index. User operations with different keys run in parallel, and each (key, sequence) pair can be used only once (nonce keys), so a retried chunk reuses its nonce and can't pay twice. If the original is still in the bundler's mempool, the retry replaces it only if it raises both gas fees by at least 10%; otherwise it's rejected. Either way, only one of them can execute.

import { keccak256, toHex } from "viem"
import { encodeNonce } from "permissionless/utils"
 
// Same run + same chunk index => same nonce. A nonce can only be used once,
// so a retried chunk can never be paid out twice.
function nonceForChunk(payoutRunId: string, chunkIndex: number): bigint {
	const key = BigInt(keccak256(toHex(`${payoutRunId}:${chunkIndex}`))) >> 64n // 192-bit key
	return encodeNonce({ key, sequence: 0n })
}

Send chunks in parallel

Send a group of chunks at a time, with a concurrency limit well below the bundler's limit of 100 parallel user operations (contact support if you need more). Persist each user operation hash with its run ID and chunk index before you wait for receipts, so a restart knows what's already in flight. A chunk that fails to send is recorded instead of stopping the run; on a rerun, chunks that already landed fail with AA25 (invalid nonce) rather than paying twice.

const CHUNK_SIZE = 100 // tune using the measurement above
const CONCURRENCY = 10 // stays well under the bundler's parallel user operation limit
 
async function sendChunk(payoutRunId: string, chunkIndex: number, items: Payout[]) {
	const userOpHash = await treasuryClient.sendUserOperation({
		calls: items.map(toTransferCall),
		nonce: nonceForChunk(payoutRunId, chunkIndex),
	})
	// Persist (payoutRunId, chunkIndex, userOpHash) before waiting
	return userOpHash
}
 
export async function runPayouts(payoutRunId: string, items: Payout[]) {
	// Parallel nonce keys only work once the Safe is deployed (see the note above)
	if (!(await treasury.isDeployed())) {
		throw new Error("Deploy the treasury Safe with a single user operation first")
	}
 
	const chunks = chunk(items, CHUNK_SIZE)
	const results: { chunkIndex: number; userOpHash?: Hex; error?: unknown }[] = []
 
	for (let start = 0; start < chunks.length; start += CONCURRENCY) {
		const group = chunks.slice(start, start + CONCURRENCY)
		const settled = await Promise.allSettled(
			group.map((chunkItems, j) => sendChunk(payoutRunId, start + j, chunkItems)),
		)
		settled.forEach((result, j) =>
			results.push(
				result.status === "fulfilled"
					? { chunkIndex: start + j, userOpHash: result.value }
					: { chunkIndex: start + j, error: result.reason }, // e.g. AA25: chunk already sent
			),
		)
	}
 
	return results
}

Confirm each chunk from its receipt

waitForUserOperationReceipt returns the receipt of this user operation only: its success flag, its actualGasCost in wei, and the logs it emitted. Count the USDC Transfer events from the treasury to confirm the chunk was paid in full.

import { formatEther, isAddressEqual, parseEventLogs } from "viem"
 
export async function confirmChunk(userOpHash: Hex, expected: Payout[]) {
	const receipt = await treasuryClient.waitForUserOperationReceipt({
		hash: userOpHash,
		timeout: 120_000,
	})
 
	if (!receipt.success) {
		// The whole chunk reverted; none of its transfers happened. Fix and resend.
		return { status: "reverted" as const, reason: receipt.reason }
	}
 
	const transfers = parseEventLogs({
		abi: erc20Abi,
		eventName: "Transfer",
		logs: receipt.logs, // only this user operation's logs
	}).filter(
		(log) => isAddressEqual(log.address, USDC) && isAddressEqual(log.args.from, treasury.address),
	)
 
	if (transfers.length !== expected.length) {
		throw new Error(`Expected ${expected.length} transfers, found ${transfers.length}`)
	}
 
	return {
		status: "paid" as const,
		transactionHash: receipt.receipt.transactionHash,
		gasCostEth: formatEther(receipt.actualGasCost),
	}
}

If a chunk reverted, remove the offending recipients and resend those payouts as a new chunk with a new chunk index.

Check user operations that are slow to land

For a hash with no receipt yet, ask the bundler for its status. Statuses are kept for one hour (pimlico_getUserOperationStatus).

const status = await pimlicoClient.getUserOperationStatus({
	hash: "0x...", // a userOpHash you persisted
})
// status.status: "not_found" | "not_submitted" | "submitted" | "rejected" | "included" | "failed" | "queued"

rejected means the user operation never executed, so the chunk can be resent with the same nonce. Because statuses expire after an hour, rely on receipts and your persisted hashes for older chunks. See debugging dropped user operations.

Estimate the cost of a payout run

There's no fixed price per recipient. Work it out from your own measurements:

  • Before sending: getRequiredPrefund on a prepared chunk is the upper bound in wei. In ERC-20 mode, pimlicoClient.estimateErc20PaymasterCost({ userOperation, token }) returns the same bound in USDC and in USD (6 decimals), see calculate costs.
  • After sending: receipt.actualGasCost is what the chunk actually cost in wei. Sum it across chunks for the run's gas spend.
  • Billing: sponsored gas is charged to your Pimlico balance (verifying paymaster FAQ); in ERC-20 mode it is paid from the treasury's USDC.

For predictable, high-volume sponsored workloads, the Boosted Paymaster (available on request) skips the paymaster round trips and bills the gas off-chain.

Related