Batch withdraw
A single transact proof spends at most five input notes. A user holding more notes than that cannot empty an asset in one withdrawal. Batch withdraw is the answer: the SDK partitions the asset's active notes into groups of at most five, proves each group as its own withdrawal, and submits all of them in one relayBatch call that pays one aggregate amount to one recipient.
Nothing changes in the circuits. Every group is an independent transact proof that enforces the same conservation, root-membership, context-binding, and nullifier rules as a standalone withdrawal. The batch is a contract-and-relayer convenience for landing them together.
How a batch withdrawal works
- Select. The SDK groups the asset's
ACTIVEnotes into single-label groups of at most five inputs, one group per transact. - Quote. One aggregate quote is requested from the relayer, which signs a per-item
PayoutRoutingfor every group. Each item's fee is what that group would cost as a standalone withdrawal, so a relayer that cherry-picked one group and submitted it alone would earn no more than its fair share. - Prove. One transact proof per group, each bound to its own signed routing and to PrivacyPoolRelay as processor. All groups prove against the same pre-batch state root.
- Submit. The relayer calls
relayBatch, which loopsPoolVault.transactover the items and then pays the summed net amount to the recipient and the summed fees to the fee recipient in one distribution.
The whole batch is atomic: if any item reverts, nothing is spent.
How to batch-withdraw
The relayed path is quote-first and mirrors prepareWithdraw / relayWithdraw:
// 1. Prepare: selects notes, fetches one aggregate quote, builds one proof per group.
// Local note state is not touched yet.
const batch = await session.prepareBatchWithdraw({
tokenId: usdcAddress, // the asset whose whole active balance leaves
recipientAddress: publicDestination,
});
// 2. Relay: submits one relayBatch through the quoting relayer, then marks notes spent
// once the receipt's Transacted logs confirm every nullifier.
const receipt = await session.relayBatchWithdraw(batch);
console.log("batch tx:", receipt.txHash);
If you would rather pay gas yourself, the self-submit path proves zero-fee items and submits relayBatch from your own wallet, with no relayer and no quote:
const receipt = await session.selfSubmitBatchWithdraw({
tokenId: usdcAddress,
recipientAddress: publicDestination,
relayContractAddress: privacyPoolRelay, // the relayBatch target
});
prepareSelfSubmitBatch and executeSelfSubmitBatch are the split halves of that call, returning a ready-to-sign relayBatch transaction (to, callData, value) plus the note-state deltas. As with any self-submitted transaction, your wallet is then publicly visible as the submitter.
Constraints
- Full balance, one asset, one recipient.
prepareBatchWithdrawtakes no amount: it withdraws the asset's entire active balance. On-chain,relayBatchrequires every item to share the sametokenIdOut,recipient, andfeeRecipient, and rejects any item with a zeroamountOut. - At most 10 groups.
MAX_BATCH = 10on both the contract and the SDK, so one batch drains up to 50 input notes. The cap is coupled to the pool: the relay's constructor requiresMAX_BATCH < PoolVault.rootHistorySize()(10 < 16 on the deployed configuration), because every item anchors to the same pre-batch state root and each transact advances the root buffer by one slot. A longer batch would evict its own snapshot and revert. - Withdrawals only.
relayBatchcarves each item's fee from its public output, so a zero-output private transfer cannot be a batch item. Transfers remain one transact each. - Not for yield-to-underlying. A withdrawal that unwraps ppUSDC to USDC binds PPRouter as its processor, and a batch item must bind PrivacyPoolRelay. Batch yield notes as ppUSDC shares instead, then unwrap; see Yield shares.
- Fees are per item. Each item's fee is
min(maxRelayFee, relayCost + (feeBps > 0 ? amountOut · feeBps / 10000 : flatFee)), where amaxRelayFeeof0means no cap. The SDK recomputes every fee from the relayer's signed parameters and rejects a mismatch (BatchRoutingInvalid). A nonzeromaxRelayFeebelowrelayCostmakes the asset unrelayable (AssetNotRelayable); a fee larger than its own item's output is infeasible (InfeasibleBatchFee). - Verify before you persist.
relayBatchWithdrawandexecuteSelfSubmitBatchparse the confirmed receipt'sTransactedlogs and only mark notesSPENTwhen every expected nullifier appears. If the receipt does not confirm them, the SDK warns and leaves local state alone rather than marking a terminal status on unverified evidence; the nextdiscoverNotes()reconciles.
Availability
relayBatch is a PrivacyPoolRelay function, so it exists only on relay contracts deployed after it was added. The production relay on mainnet, BNB Chain, and Citrea answers MAX_BATCH() with 10, so batch withdrawal is available there. The Sepolia V9 relay in the Sepolia page predates it and exposes neither relayBatch nor MAX_BATCH(). Before offering batch withdrawal, read the relayer's /v1/details and confirm the contracts.relay it publishes answers MAX_BATCH(); a relayer that does not support batches returns an error from its batch quote route.
Behind the scenes
session.prepareBatchWithdraw(...) then session.relayBatchWithdraw(...); or session.selfSubmitBatchWithdraw(...). Lower-level pieces are exported for integrators building outside a session: selectNotesForFullWithdraw, computeBatchItemFee / computeBatchFees, and MAX_BATCH, the last three also on the dependency-light @privacy-pools-v2/sdk/batch-fee subpath.PrivacyPoolRelay.relayBatch(proofs[], transactParams[], noteData[][]), which calls PoolVault.transact once per item and emits one RelayedBatch.POST /v1/quote/evm/{chainId}/batch for the aggregate quote; the relayer signs a PayoutRouting per item and submits the single relayBatch.transact_NxM proof per group, N ≤ 5, each a withdrawal with a non-zero amountOut.What's next
- Withdraw: the single-proof path for five notes or fewer.
- Yield withdraw: unwrapping a yield note to its underlying, which cannot be batched.
- Manage notes: confirming the spent state after the receipt lands.