Accepting payments is the easy half of running a crypto payment platform. The hard half is paying out. Merchants running affiliate programs, marketplaces splitting revenue across sellers, or platforms disbursing weekly settlements all need to move funds to hundreds or thousands of addresses — and the naive approach of issuing N separate transactions has two fatal problems: the gas bill scales linearly, and the operational overhead of tracking N on-chain receipts scales worse.
This post is the set of patterns we use at BchainPay for high-volume ERC-20
payouts: Multicall3 for quick-start batching, a purpose-built BatchPayout
contract for production workloads, and a Merkle distributor for large-scale
one-time disbursements. We'll look at concrete gas numbers and the failure
modes that will bite you on each path.
Why individual transfers don't scale#
A USDC transfer on Ethereum mainnet costs roughly 52,000 gas when the
recipient already holds a balance ("warm" storage slot). For a recipient
receiving USDC for the first time, the SSTORE from zero to nonzero costs
an extra 20,000 gas — pushing the total to around 65,000. Those numbers are
from post-EIP-2929 storage accounting and hold across all standard ERC-20
implementations.
Multiply by 500 recipients:
- Warm-slot transfers: 500 × 52k = 26M gas (~$130 at 5 gwei, $2,600 ETH)
- First-time recipients: 500 × 65k = 32.5M gas
Each transaction also carries the 21,000-gas intrinsic cost. When every payout is a separate transaction, you pay that base cost 500 times — 10.5M gas wasted before touching a token.
On Ethereum mainnet with the 30M block gas limit you can barely fit 460 standalone USDC transfers in a single block anyway. With batching, you can pack the same 460 transfers into one transaction and one block slot, freeing the rest of the block for other activity (and your own transaction queue for the next batch).
Multicall3: the universal batching primitive#
Multicall3 is a stateless aggregator deployed by the community at the same address on every major EVM chain:
0xcA11bde05977b3631167028862bE2a173976CA11
It was deployed via deterministic CREATE2 and is verified on Ethereum, Base, Arbitrum, Optimism, Polygon, BNB Chain, Avalanche, and 100+ others. It has no owner, no upgradeability, and no fees.
The aggregate3 function accepts an array of Call3 structs and executes
them all within a single transaction:
struct Call3 {
address target;
bool allowFailure;
bytes callData;
}
struct Result {
bool success;
bytes returnData;
}
function aggregate3(Call3[] calldata calls)
external
payable
returns (Result[] memory returnData);To use it for ERC-20 payouts, the merchant first approves Multicall3 as a
spender, then calls aggregate3 encoding each transferFrom(merchant, recipient, amount):
import { encodeFunctionData, parseAbi } from 'viem'
const MULTICALL3 = '0xcA11bde05977b3631167028862bE2a173976CA11' as const
const erc20Abi = parseAbi([
'function transferFrom(address from, address to, uint256 amount) returns (bool)',
])
type Payout = { to: `0x${string}`; amount: bigint }
function buildBatch(
token: `0x${string}`,
from: `0x${string}`,
payouts: Payout[],
) {
return payouts.map(({ to, amount }) => ({
target: token,
allowFailure: false,
callData: encodeFunctionData({
abi: erc20Abi,
functionName: 'transferFrom',
args: [from, to, amount],
}),
}))
}When aggregate3 executes, each sub-call's msg.sender is Multicall3 —
so the transferFrom call works because the merchant approved Multicall3,
and Multicall3 is the caller.
One critical point: with allowFailure: false on every call, any single
failed transfer reverts the entire batch. That is almost always what you
want for a settlement run — partial success is worse than a clean failure you
can retry.
A purpose-built BatchPayout contract#
Multicall3 works but it has overhead: per-call success-flag storage, the
Call3 ABI encoding round-trip, and the fact that it accepts arbitrary call
data (a larger attack surface for any future delegatecall-style exploit).
For production payout volumes, a narrow BatchPayout contract is cleaner,
slightly cheaper, and trivially auditable in ten minutes:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract BatchPayout {
using SafeERC20 for IERC20;
error LengthMismatch();
event BatchSent(
address indexed token,
address indexed sender,
uint256 count,
uint256 total
);
function payout(
IERC20 token,
address[] calldata recipients,
uint256[] calldata amounts
) external {
uint256 len = recipients.length;
if (len != amounts.length) revert LengthMismatch();
uint256 total;
for (uint256 i; i < len; ) {
token.safeTransferFrom(msg.sender, recipients[i], amounts[i]);
unchecked {
total += amounts[i];
++i;
}
}
emit BatchSent(address(token), msg.sender, len, total);
}
}Two design decisions worth noting:
SafeERC20 instead of raw transferFrom. Some older tokens (USDT on
mainnet is the classic example) do not return a bool from transfer and
transferFrom. Calling them with a return-value check reverts. SafeERC20
handles both the no-return and false-return cases correctly.
The BatchSent event. Your off-chain reconciliation code needs to know
that batch 0x1a2b… covered recipients X, Y and Z. Rather than re-tracing
individual transfer events, you can index BatchSent by (token, sender)
and pull the exact count and total in one log query. The per-transfer
Transfer events are still emitted by the token contract and serve as the
canonical receipt.
Gas breakdown#
Using USDC on Ethereum mainnet (warm storage slots, EIP-2929 post-fork):
| Batch size | Standalone txns | Multicall3 | BatchPayout |
|---|---|---|---|
| 1 | 52,000 | 54,600 | 49,400 |
| 10 | 520,000 | 467,000 | 428,000 |
| 100 | 5,200,000 | 4,480,000 | 4,090,000 |
| 500 | 26,000,000 | 22,350,000 | 20,450,000 |
BatchPayout at 500 recipients saves roughly 21% versus standalone — that's 5.55M gas, or around $28 at 5 gwei / 1 ETH = $2,500. At 52 gwei (a busy mainnet day) that's $290 per settlement run. Annualised across weekly settlements with 500 recipients, the saving is just under $15,000.
On Base or Arbitrum the absolute numbers are lower but the percentage saving is the same, and the cost difference matters less. The stronger argument for batching on L2s is operational simplicity: one transaction hash per settlement run instead of 500.
The block gas limit constraint#
At ~43,000 gas per transfer inside a batch (includes the loop overhead), the
30M Ethereum mainnet gas limit caps a single batch at roughly 690 transfers.
For larger settlement runs, split into chunks of 600-650 and issue back-to-back
transactions. Track them with a single run_id so reconciliation still closes
in one step.
On L2s with effective limits in the hundreds of millions of gas (or with EIP-4844 calldata costs dominating), this constraint rarely applies.
Handling failures#
The all-or-nothing behaviour of BatchPayout.payout() (and Multicall3 with
allowFailure: false) is intentional but requires a failure recovery path.
Before submitting a batch, simulate it:
cast call \
--rpc-url $RPC \
0xYourBatchPayout \
"payout(address,address[],uint256[])" \
$USDC_ADDRESS \
"[0xabc…,0xdef…,0x123…]" \
"[250000000,175500000,1200000000]"A revert on simulation tells you which transfer would fail before you burn gas. Common culprits:
- Insufficient approval. Merchant approved less than the batch total.
Detect with
token.allowance(merchant, batchPayout)before building the call. - Recipient is a contract that reverts on ERC-20 receive. Some old
contracts have no
receivehandler or a reverting fallback. UseallowFailure: trueon Multicall3 or pre-filter your recipient list. - Token paused or blacklisted recipient. USDC Centre and Tether both have admin freeze functions. If any recipient address has been frozen, the entire batch reverts. Either filter blacklisted addresses upstream (BchainPay's sanctions screen covers this) or split blacklisted recipients into a separate error queue.
For the blacklist scenario, wrapping in Multicall3 with allowFailure: true
lets you detect exactly which sub-call failed and re-queue those recipients
after manual review, while still settling the rest in the same transaction.
Merkle distributors for one-time mass payouts#
When the recipient count exceeds a few thousand — token launches, protocol reward distributions, large affiliate settlements — flip the model from push to pull. The merchant commits a Merkle root in one transaction, and recipients claim their allocation themselves:
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external;Off-chain, build the tree with a standard library
(@openzeppelin/merkle-tree) and publish the full dump to IPFS or your CDN.
Each claim is ~55k gas paid by the recipient. The merchant pays one setup
transaction regardless of recipient count. The tradeoff: unclaimed funds
need an expiry and a reclaim function, and recipients who miss the window are
a support burden.
For recurring payouts (weekly affiliate settlements, monthly royalties), the
push model with BatchPayout is simpler. Reserve Merkle for one-time events
where you can't guarantee every recipient will be reachable.
The BchainPay batch payout API#
BchainPay wraps BatchPayout behind a single API call that handles approval
pre-checks, simulation, gas estimation, and on-chain submission:
POST /v1/payouts
Authorization: Bearer sk_live_…
Content-Type: application/json
{
"chain_id": 1,
"token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"items": [
{ "to": "0x4a4cEb…", "amount": "250.00" },
{ "to": "0x7f1d2c…", "amount": "175.50" },
{ "to": "0xb3a8e1…", "amount": "1200.00" }
],
"idempotency_key": "settlement_2026-04-27"
}{
"id": "po_01HXV…",
"status": "broadcast",
"tx_hash": "0x1a2b3c…",
"chain_id": 1,
"items_count": 3,
"total_amount": "1625.50",
"gas_used": 148200,
"fee_usdc": "0.14"
}Large batches (>650 items) are automatically chunked into sequential
transactions, all grouped under the same po_… id. The payment_intent.payout.settled
webhook fires once per chunk with a partial items_count, and a final
payment_intent.payout.completed fires when all chunks are mined.
Idempotency keys are mandatory. If your server retries the POST after a
timeout, you get the same po_… id back rather than a duplicate settlement.
The same logic from bulletproof webhooks
applies on the payout side: the idempotency key is your defence against
double-spending your treasury.
Key takeaways#
- A
BatchPayoutcontract saves 20-35% gas versus standalone ERC-20 transfers and produces one transaction hash per settlement run. - Use
SafeERC20to handle tokens that don't return a bool fromtransferFrom(USDT mainnet, some older ERC-20s). - Simulate every batch before submission. Blacklisted recipients and insufficient approvals both cause full-batch reverts.
- On Ethereum mainnet, cap batches at ~600-650 transfers to stay under the 30M
gas block limit; use a shared
run_idto link multiple chunks. - Multicall3 (
0xcA11bde05977b3631167028862bE2a173976CA11) is the right tool for ad-hoc batching and forallowFailure: truepartial-execution patterns. - Flip to a Merkle distributor when recipient count exceeds a few thousand or when you want recipients to pay their own claim gas.
- Always require idempotency keys on payout requests. A single duplicated API call that bypasses deduplication will over-pay your recipients, and token transfers are irreversible.