The dirty secret of running a crypto payment gateway at scale isn't accepting the money. Acceptance is the easy part: derive an address, watch a mempool, fire a webhook. The hard part is what happens next — moving thousands of small balances out of thousands of deposit addresses, on five different chains, into a single treasury without spending so much on gas that you eat the merchant's margin.
This is the sweep problem. It is unglamorous, it is where real money quietly disappears, and almost nobody writes about how to do it well. This post is the set of patterns we use at BchainPay to keep sweep costs under 8 basis points of TPV across Ethereum, Polygon, BNB Chain, Solana and Tron.
What "sweep" actually means#
When a merchant accepts a stablecoin payment, the funds land at a unique deposit address derived from the merchant's xpub (EVM and Bitcoin) or program-derived account (Solana, Tron). That address is disposable: it exists to identify the payment. The merchant doesn't want their money sitting across 40,000 dust addresses — they want it in one hot treasury and one cold treasury, ready to withdraw.
A sweep is the on-chain transaction that moves those funds. Done naively, it looks like this:
for (const deposit of pendingDeposits) {
await wallet(deposit.privKey)
.sendTransaction({ to: TREASURY, value: deposit.balance });
}This works. It is also the fastest way we've seen to nuke margin. A single ERC-20 transfer on Ethereum costs ~$0.40 to $4.00 depending on gas. Sweep a $12 USDC deposit at $1.80 of gas and the merchant just lost 15% of the payment to the network. Multiply by thousands of deposits per day and the gateway is upside-down.
Every pattern below exists to fix one of three problems:
- Per-address gas overhead. Each sweep is a tx; each tx has a fixed cost.
- Funding the sweeper. ERC-20 sweeps need ETH at the deposit address (or a different model entirely). That ETH is your money.
- Reorg and finality risk. Sweeping before finality means sweeping ghosts.
Pattern 1: don't sweep at all (forwarding contracts)#
The cheapest sweep is the one that never happens, because the funds never landed in a separate address in the first place.
On EVM chains, deploy a minimal-proxy Forwarder per merchant. The
deposit address is the proxy itself. When the user pays, the proxy's
fallback function (or an explicit flush(token) call) moves funds to
the treasury in the same transaction window — sometimes triggered by
the same tx that funded it via a flash callback.
contract Forwarder {
address immutable treasury;
constructor(address t) { treasury = t; }
function flush(IERC20 token) external {
uint256 bal = token.balanceOf(address(this));
if (bal > 0) token.transfer(treasury, bal);
}
}Two ways this saves money:
- Counterfactual deployment. You compute the proxy address with
CREATE2 from
(merchantId, paymentIntentId)and only deploy the proxy on the first sweep. For deposits below a configurable threshold, you skip deployment entirely and let the dust sit until it's worth picking up. - Single-tx sweep. No "send funds to A, then A sends to B" two-hop
cost. One
flush()call, one gas charge, and the proxy is reusable for the same merchant's next deposit.
Per-address gas drops from ~65k (a normal transfer) to ~48k after
the proxy is warm — about a 26% saving — and CREATE2 lets you derive
addresses without touching chain at all until you need to.
This pattern only fits chains with cheap contract deployment. It works beautifully on Polygon, Base, Arbitrum and BNB Chain. It is borderline on Ethereum L1 unless the merchant's per-address volume is high. It does not apply to Solana (no per-address contracts) or Tron (high deployment cost relative to value moved).
Pattern 2: batched sweeps via Multicall3#
When forwarders aren't a fit — Tron, low-volume merchants on L1, or legacy deposit addresses already in production — batch the sweeps themselves.
Every EVM chain we support has Multicall3 deployed at
0xcA11bde05977b3631167028862bE2a173976CA11. We use a thin
SweepRouter contract that takes a list of (token, from, value)
tuples and a list of pre-signed transferFrom allowances:
function sweepMany(SweepCall[] calldata calls) external onlyOperator {
for (uint256 i; i < calls.length;) {
SweepCall calldata c = calls[i];
IERC20(c.token).transferFrom(c.from, c.treasury, c.value);
unchecked { ++i; }
}
}Per-call gas drops from a fixed ~65k to roughly 34k amortized at a batch size of 16. The savings curve flattens beyond ~24 calls per batch because of the 30M gas block ceiling and Geth's transaction-size warnings.
The two operational details that matter:
- Batch by chain, not by merchant. A sweep batch is a single tx; the unit of optimization is the chain's gas price and block. Group every ready-to-sweep deposit on Polygon together regardless of which merchant they belong to, then fan out the credits at the database layer.
- Cap batch size by gas, not by count. We simulate the batch with
eth_estimateGas, target 12M gas per submission, and split when the estimate exceeds it. Hard-coded "always do 20" is brittle — token contracts have wildly differenttransferFromcosts. USDT on Ethereum costs roughly 50% more per call than USDC.
Pattern 3: gas funding without the round trip#
For non-forwarder sweeps, the deposit address needs gas. The naive flow is: send 0.001 ETH to address A, wait, then sweep. That's two transactions and two gas charges — you've doubled your cost to save an unrelated cost.
A few patterns we use to avoid the round trip, in increasing order of sophistication:
- EIP-3009 / Permit2 sweeps. If the deposited token is USDC,
PYUSD, or anything that supports
transferWithAuthorizationorpermit, the depositor's signature isn't required (it's the merchant's address) — but the merchant can pre-sign a sweep authorization at deposit-address creation time and the sweeper submits it directly from a hot wallet that holds gas. The deposit address never needs ETH. - Account abstraction (ERC-4337). Deposit addresses are smart accounts with a paymaster sponsoring gas. The paymaster's USDC balance is debited per sweep at a fixed conversion rate. We use this on Base where bundler costs are low.
- EIP-7702 delegations. New as of late 2025, this lets a normal EOA temporarily behave like a smart account for a single transaction. We're piloting it for high-value merchants who want forwarder-like savings without deploying anything per address.
- Native gas top-up batching. When none of the above fit, batch the gas top-ups too: one Multicall3 tx that sends 0.0008 ETH to twenty deposit addresses at once, then twenty parallel sweeps. The top-up amortizes to ~21k gas per recipient instead of 21k + base cost per tx.
Whichever you pick, the rule is: the deposit address should never require a dedicated funding tx of its own. Either it is a smart account, or the gas comes from a batched top-up, or the sweep itself is gasless from the address's perspective.
Pattern 4: trigger logic that respects finality#
A sweep that runs the instant a deposit confirms is a sweep that runs during a reorg. We've seen 7-block reorgs on Polygon PoS during validator instability and 2-block reorgs on BNB Chain after a contentious upgrade. Sweep too early and you're moving funds that might not exist when the dust settles.
Our trigger has three conditions, all of which must hold:
function shouldSweep(deposit: Deposit): boolean {
const finalityOk =
deposit.confirmations >= POLICY[deposit.chain].finalityBlocks;
const economicsOk =
deposit.balanceUsd >= POLICY[deposit.chain].minSweepUsd
|| deposit.ageHours >= POLICY[deposit.chain].dustMaxHours;
const gasOk =
currentGasGwei() <= POLICY[deposit.chain].gasCeilingGwei
|| deposit.ageHours >= POLICY[deposit.chain].forceSweepHours;
return finalityOk && economicsOk && gasOk;
}The policy table is the only thing that changes per chain:
{
"ethereum": { "finalityBlocks": 32, "minSweepUsd": 25, "dustMaxHours": 168, "gasCeilingGwei": 25, "forceSweepHours": 96 },
"polygon": { "finalityBlocks": 64, "minSweepUsd": 5, "dustMaxHours": 72, "gasCeilingGwei": 200, "forceSweepHours": 48 },
"bsc": { "finalityBlocks": 15, "minSweepUsd": 5, "dustMaxHours": 72, "gasCeilingGwei": 5, "forceSweepHours": 48 },
"tron": { "finalityBlocks": 19, "minSweepUsd": 10, "dustMaxHours": 96, "gasCeilingGwei": 0, "forceSweepHours": 72 },
"solana": { "finalityBlocks": 32, "minSweepUsd": 5, "dustMaxHours": 72, "gasCeilingGwei": 0, "forceSweepHours": 48 }
}A deposit gets swept when it's safe (finality), worth sweeping (economics), and the network isn't on fire (gas). The age-based escapes prevent dust from sitting forever.
The non-obvious detail: finality blocks should track the actual deepest reorg you've observed in the last 90 days, plus a margin. Don't trust marketing-page numbers. We log every reorg the indexer sees and rebuild the policy table monthly from production data.
Pattern 5: Solana and Tron need different shapes#
EVM patterns don't translate cleanly. Two short notes from production:
Solana. There is no per-address contract. The cheapest pattern is
to use a single program-derived address (PDA) per merchant as the
deposit point and tag incoming SPL transfers with a memo containing
the payment intent ID. Sweeps become unnecessary — the funds already
sit in a merchant-owned account; you only need to move from "deposit
PDA" to "treasury PDA" when the balance crosses a threshold. One
spl-token transfer per sweep, ~5,000 lamports of fee. The interesting
work is parsing the memo and reconciling the intent.
Tron. USDT on Tron is the highest-volume stablecoin in the world, and its energy/bandwidth model means a TRC-20 transfer "costs" zero TRX if you've staked enough TRX for energy. We maintain a staking buffer sized to peak hourly throughput and sweep without thinking about gas at all. The trade-off is locked TRX as opex; the saving is a sub-1bp effective sweep cost.
A worked example#
A mid-size merchant doing $4M/month across all chains with an average deposit size of $38 generates roughly 105,000 deposits monthly.
- Naive per-deposit sweep on EVM at average $0.55 of gas: $57,750 per month, or 144 bps of TPV. Brutal.
- Forwarder + Multicall3 batching at average $0.18 effective per deposit: $18,900, or 47 bps.
- Add EIP-7702 / 4337 sponsored sweeps for the high-volume merchants, Tron stake-for-energy, and Solana PDA model: blended cost $3,100, or 7.7 bps of TPV.
The patterns aren't theoretical. Every basis point you don't spend sweeping is a basis point you can return to the merchant or invest in better confirmation policies. At any meaningful volume the difference between "sweeping is fine" and "sweeping is a business" is two orders of magnitude.
Key takeaways#
- The cheapest sweep is no sweep — counterfactual forwarder contracts let you skip deployment until the value justifies it.
- Batch sweeps with Multicall3 and group by chain, not by merchant. Cap batches by gas estimate, not by count.
- Never fund a deposit address with a dedicated tx. Use EIP-3009, Permit2, ERC-4337 paymasters, EIP-7702 or batched top-ups.
- Trigger sweeps only when finality, economics and gas all agree. Build policy tables from observed reorg depth, not marketing copy.
- Solana and Tron break the EVM mental model. Use PDAs and staked energy respectively; don't try to port forwarder patterns over.
- Real-world result: a properly engineered multi-chain sweeper runs at under 10 bps of TPV. A naive one runs at over 100. The gap pays for the entire engineering team that builds it.