BchainPay logoBchainPay
EngineeringEthereumEVMSettlementERC-20

Batch ERC-20 payouts with Multicall3 for high-volume settlement

Pack hundreds of ERC-20 payouts into one EVM transaction with Multicall3 — cut gas by 30-50% and simplify settlement reconciliation at scale.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for Batch ERC-20 payouts with Multicall3 for high-volume settlement

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 receive handler or a reverting fallback. Use allowFailure: true on 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 BatchPayout contract saves 20-35% gas versus standalone ERC-20 transfers and produces one transaction hash per settlement run.
  • Use SafeERC20 to handle tokens that don't return a bool from transferFrom (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_id to link multiple chunks.
  • Multicall3 (0xcA11bde05977b3631167028862bE2a173976CA11) is the right tool for ad-hoc batching and for allowFailure: true partial-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.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading