Skip to main content

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

  1. Select. The SDK groups the asset's ACTIVE notes into single-label groups of at most five inputs, one group per transact.
  2. Quote. One aggregate quote is requested from the relayer, which signs a per-item PayoutRouting for 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.
  3. 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.
  4. Submit. The relayer calls relayBatch, which loops PoolVault.transact over 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. prepareBatchWithdraw takes no amount: it withdraws the asset's entire active balance. On-chain, relayBatch requires every item to share the same tokenIdOut, recipient, and feeRecipient, and rejects any item with a zero amountOut.
  • At most 10 groups. MAX_BATCH = 10 on 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 requires MAX_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. relayBatch carves 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 a maxRelayFee of 0 means no cap. The SDK recomputes every fee from the relayer's signed parameters and rejects a mismatch (BatchRoutingInvalid). A nonzero maxRelayFee below relayCost makes the asset unrelayable (AssetNotRelayable); a fee larger than its own item's output is infeasible (InfeasibleBatchFee).
  • Verify before you persist. relayBatchWithdraw and executeSelfSubmitBatch parse the confirmed receipt's Transacted logs and only mark notes SPENT when 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 next discoverNotes() 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

SDK call 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.
Contract method PrivacyPoolRelay.relayBatch(proofs[], transactParams[], noteData[][]), which calls PoolVault.transact once per item and emits one RelayedBatch.
Relayer route POST /v1/quote/evm/{chainId}/batch for the aggregate quote; the relayer signs a PayoutRouting per item and submits the single relayBatch.
Circuit One 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.