Skip to main content

Paying many recipients

This guide pays N recipients from one wallet's notes in a single cycle. It is the pattern the Payroll PoC implements.

Constraints to respect

The cycle has to fit the circuit's limits.

  • 5×5 circuit cap. A single transact call takes at most 5 input notes and 5 output notes, so larger cycles split across multiple transactions.
  • Label-aware output count. The output count is also label-aware: each distinct deposit label among the inputs adds two outputs (one recipient note plus one change note), which means at most two labels per call if the outputs are to fit the 5x4 variant.
  • Chain change forward. After each transact, the change note becomes a fresh input for the next line item. Keep a running pool of spendable notes as you go.

Outline

async function runCycle(recipients, treasury) {
const pool = await loadAvailableNotes(treasury);

for (const r of recipients) {
// 1. Pick inputs (label-aware, chain-verified spent check)
const inputs = pickInputs(pool, r.amount, r.asset);
if (!inputs) {
failures.push({ recipient: r, reason: "unallocated" });
continue;
}

// 2. Prepare + relay
const prepared = await session.prepareTransfer({
inputCommitments: inputs.map(n => n.commitment),
amount: r.amount,
tokenId: r.asset,
recipientDiscoveryData: {
evmAddress: r.recipient,
},
});
const result = await session.relayTransfer(prepared.relayOptions[0]);

// 3. Persist receipt (per-line-item)
const recipientNote = prepared.executeOptions.recipientPendingNotes[0];
await persistReceipt({
recipient: r,
txHash: result.txReceipt.txHash,
note: recipientNote,
});

// 4. Update running pool: remove spent inputs, add change notes
removeFromPool(pool, inputs);
for (const change of prepared.executeOptions.changePendingNotes) {
if (isOwned(change)) addToPool(pool, change);
}
}
}

Per-input chain-verified spent check

Wrap pickInputs so it skips notes whose nullifier is on-chain even if local state says ACTIVE. See verify spent.

Error recovery

  • Relayer reverts mid-cycle: log the failure, drop the picked inputs from the pool (they may or may not be spent, so refresh via discoverNotes before the next run), and continue with the other line items.
  • Quote expiry: retry quote-expiry failures (FeeCommitmentExpired or a relayer rejection at submit time) by re-running prepareTransfer for a fresh quote, and cap the number of retries.
  • NoteNotFoundError: the NoteManager has drifted from chain state. Call discoverNotes before re-picking.

UI

The PoC's /org/cycles/[id] page shows per-line-item status and a Download Receipt link for each settled item. See the receipt guide for the receipt half.