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)— popsmaxNqueue 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 = 1is 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 usemaxN = 2and 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
StarkCommitmentInsertedV4fires (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 few invariants worth knowing:
head ≤ tail, always.head == tailmeans 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
Transferredevent (see next section). - Each commit pushes exactly 2 entries. Each flush pops exactly
maxNentries (1 in the demo). Calls that would pop pasttailrevert withFlushQueueEmpty. - 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:
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
pendingQueueIndextells 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
Transferredlog.
Event topology
| Event | Topics (indexed) | Carries | Emitted by | Fires per |
|---|---|---|---|---|
Transferred | nullifier, outputCommitment1, outputCommitment2 | encryptedNote1, encryptedNote2 | transferSTARKV4Commit | transfer |
StarkTransferV4Committed | nullifier | queueIndex1, queueIndex2 | transferSTARKV4Commit | transfer |
StarkPendingV4Flushed | queueIndex | commitment, leafIndex | flushPendingV4Inserts | flushed leaf |
StarkCommitmentInsertedV4 | commitment | leafIndex | flushPendingV4Inserts | flushed 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 - pendingV4Headand runsflushPendingV4Inserts(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
- V4 Octary State Tree — The tree that the flush half of this protocol inserts into
- Known Limitations §3 — The user-facing UX cost of the manual flush
- Indexer SDK reference — How
pendingQueueIndexand the positional join are wired in the sync engine
V4 Octary State Tree
The Poseidon31 8-ary Merkle tree that holds every STARK commitment on UPP — geometry, the hashEight primitive, capacity, and the gas reality that motivated the commit/flush split.
SDK Overview
UPP SDK module structure, subpath exports, and integration paths — a composable toolkit, not a monolithic client.