UPP — Universal Private PoolConcepts

V4 Commit/Flush Transfers

Why a V4 STARK transfer is two transactions instead of one, the FIFO queue between them, and the spendability lifecycle the recipient's note moves through.

A V4 transfer in UPP is two on-chain calls, not one. The first is small and proves the spend; the second (twice over) does the heavy work of inserting the output commitments into the V4 state tree. This page covers why the split is mandatory, how the two halves communicate through an on-chain FIFO queue, and the visibility lifecycle the recipient's note moves through before it becomes spendable.

The V4 Octary State Tree page is the prerequisite — the gas reality it establishes is the entire reason this split exists.

Why two transactions

An atomic V4 transfer would cost roughly 28M gas — about 4M for SP1 proof verification on the wrapped Groth16 proof, then 2 × ~12M for the two octary inserts of the output commitments. Sepolia's block gas limit (30M) could mine that. No wallet RPC will submit it, though: every public RPC provider — Infura, Alchemy, dRPC, Tenderly, Chainstack — pre-validates the tx against rpc.gascap defaulted to 2²⁴ = 16,777,216 gas. A 28M transaction is rejected before it reaches the mempool. (See Known Limitations §4 for the longer story.)

The split that fits:

  • transferSTARKV4Commit(params) — verifies the proof, burns the nullifier, enqueues the two output commitments into a pool-resident FIFO queue. ~230K gas.
  • flushPendingV4Inserts(maxN) — pops maxN queue entries and inserts them into the V4 tree. ~12M gas per leaf. Permissionless: anyone can call it.

A complete transfer is one commit and two flushes. Each call comfortably fits under the 16.77M ceiling.

The two-step lifecycle

A few mechanics worth pinning down explicitly:

  • The nullifier burns at commit time. Step 1 records the spend on-chain (_nullifierUsedV4[nullifier] = true) the moment the proof verifies. The input note is gone from the active set immediately, regardless of when the outputs land. Important for replay safety; also important for the STARK nullifier verifier — spent-state on the input side doesn't wait for flush.
  • maxN = 1 is the demo's discipline. Each leaf insert is ~12M gas. Two in one transaction would land at ~24M, back over the RPC cap. A flusher running against a node with --rpc.gascap=0 (no cap) could safely use maxN = 2 and finish the transfer in one tx, but no public RPC qualifies.
  • The recipient note isn't spendable until its flush lands. Step 3 commits the output commitments to the queue, but the V4 tree doesn't know about them yet — there's no Merkle proof of inclusion to build a witness from. Only after StarkCommitmentInsertedV4 fires (steps 5 and 7) does the leafIndex exist on chain.

The FIFO queue

The pool maintains a monotonic queue of pending V4 inserts in storage. Two cursors track its state: pendingV4Head (next item to dequeue) and pendingV4Tail (next slot to enqueue). Commit pushes 2 items at the tail; each flush pops 1 from the head.

A — before commithead = tail = 4 · queue is emptyslot 0slot 1slot 2slot 3slot 4slot 5↑ head = tail = 4B — after transferSTARKV4Commithead = 4, tail = 6 · two commitments enqueuedslot 0slot 1slot 2slot 3c₁c₂↑ head = 4↑ tail = 6C — after first flushPendingV4Inserts(maxN=1)head = 5, tail = 6 · c₁ inserted into the V4 treeslot 0slot 1slot 2slot 3✓ c₁c₂↑ head = 5↑ tail = 6

A few invariants worth knowing:

  • head ≤ tail, always. head == tail means the queue is empty; that's the steady state.
  • Queue indices are monotonically increasing for the lifetime of the pool. Slot 4 here is the 4th-ever queued commitment on this contract; it doesn't get re-used after being drained. The indexer relies on this for its positional join with the Transferred event (see next section).
  • Each commit pushes exactly 2 entries. Each flush pops exactly maxN entries (1 in the demo). Calls that would pop past tail revert with FlushQueueEmpty.
  • The queue lives in pool storage. Storage growth is bounded by tail - head, which only grows when commits outpace flushes. In steady state — flushes keeping up with commits — the high-water mark stays low.

Positional join: commit event ↔ queue index

The indexer has to figure out which queue index belongs to which output commitment of a transfer. The pool helpfully emits two indexed events at commit time:

Transferred(event, indexed topics shown)nullifieroutputCommitment1outputCommitment2encryptedNote1, 2StarkTransferV4Committed(same tx, same nullifier)nullifierqueueIndex1queueIndex2pairs positionally

The two events fire from the same transaction. The indexer scans StarkTransferV4Committed to build a txHash → [queueIndex1, queueIndex2] map, then walks the Transferred log and pairs by transaction hash. outputCommitment1 gets queueIndex1, outputCommitment2 gets queueIndex2. There's no other identifier shared between the two — the pairing is purely positional, and that's a place subtle off-by-one bugs love to live. The SDK pins this in src/indexer/sync-engine.ts and has tests asserting the order survives an ABI regen.

Why not just put queueIndex on the Transferred event?

The Transferred event is shared between SNARK transfers, V3 STARK transfers, and V4 commits. Adding queue fields to it would mean changing the SNARK and V3 code paths to emit nonsense values, plus widening every existing indexer's view of the event. A separate event for the V4-only queue payload keeps the signature stable.

Spendability lifecycle

A note created by a V4 transfer moves through three states the indexer can observe. Each state has a distinct local representation in the SDK's ShieldedNote:

The committed → inserted transition is the bit the recipient's wallet has to wait for. Until StarkCommitmentInsertedV4 fires for this commitment, there's no leaf in the tree to point a Merkle proof at — leafIndex stays at -1. The UI surfaces this as an "awaiting flush" badge so the recipient understands why their balance hasn't moved yet.

pendingQueueIndex is set at commit time and persists as an audit trail even after the leaf is inserted. The indexer uses it for two things:

  • Display: a note's pendingQueueIndex tells the UI which flush call landed this leaf, useful when reasoning about pending state.
  • Provenance: the (commit tx, queueIndex) pair lets anyone re-derive which transfer produced this output, without re-running decryption against the entire Transferred log.

Event topology

EventTopics (indexed)CarriesEmitted byFires per
Transferrednullifier, outputCommitment1, outputCommitment2encryptedNote1, encryptedNote2transferSTARKV4Committransfer
StarkTransferV4CommittednullifierqueueIndex1, queueIndex2transferSTARKV4Committransfer
StarkPendingV4FlushedqueueIndexcommitment, leafIndexflushPendingV4Insertsflushed leaf
StarkCommitmentInsertedV4commitmentleafIndexflushPendingV4Insertsflushed leaf

Transferred and StarkTransferV4Committed always fire together (same call, same tx); StarkPendingV4Flushed and StarkCommitmentInsertedV4 always fire together (same flush call, same leaf). The indexer's job is mostly joining the right pairs.

Anyone can flush

flushPendingV4Inserts is deliberately permissionless — no access control, no ordering constraint, no caller binding. The intent: a flush is just labour, not authorization. The proof was already verified at commit time, the nullifier was already burned, the queue entries are already committed to storage. All flushing does is move data from the FIFO into the tree.

In practice that means a transfer can be finalized by:

  • The sender — natural for the demo, where the transfer UI prompts to flush right after commit.
  • The recipient — if they're impatient and want their balance to settle.
  • An MEV-style watcher — if there's any economic incentive to drain the queue.
  • A protocol-run flusher service — the production answer. A small cron / sidecar that polls pendingV4Tail - pendingV4Head and runs flushPendingV4Inserts(1) whenever the queue is non-empty. Out of scope for the demo (see Known Limitations §3).

What if no one flushes?

The sender's input note is already burned at commit time, so it can't be re-spent. The output commitments are in the queue, addressable, and will land in the tree the moment any flush happens. Until then the recipient's note is in the committed state — not lost, just not yet spendable. The pool can't "lose" a flush; the cost just sits on whoever finally calls it.

See also

On this page