Skip to main content

What is a Stealth Pool Note?

A Stealth Pool Note is a UTXO-style commitment leaf in the mixer’s Indexed Merkle Tree. The SDK surfaces these commitments as “notes”, and the act of consuming one is a “burn”. A note encodes:
  • Amount — how many tokens are locked.
  • Destination address — who is authorised to burn.
  • Secret randomness — private entropy known only to the writer (and, for receiver-burnable notes, the recipient).
Only the Poseidon hash of these values (the commitment) is stored on-chain. The inputs remain private.

The Stealth Pool: How It Works

The Stealth Pool is a shared Indexed Merkle Tree stored on-chain. Each leaf in the tree is a note commitment.
1

Write a note

You call one of the four …StealthPoolNote…Creator factory functions (see below). The SDK computes a commitment from (amount, destination, randomness) and inserts it as a new leaf into the tree. Your tokens are locked in the pool custody account.At this point, anyone can see that a note was written and the tree grew by one leaf — but cannot see the amount, destination, or any other detail.
2

Build the anonymity set

As more users write notes into the same tree, your commitment becomes one of many. The larger the set, the harder it is to link the write to the eventual burn. Trees hold up to 1,048,576 leaves (depth-20 Indexed Merkle Tree).
3

Scan

The SDK queries the indexer for note ciphertexts, attempts to decrypt each with your viewing keys, and groups successful decrypts by (burn kind, source). The scanner is zero-arg — it walks every active tree and tracks its own cursor in client.utxoDataStore. See Scanning.
4

Burn

You present a zero-knowledge proof that proves:
  • You know the secret inputs behind a commitment that exists in the tree.
  • You haven’t burnt it before (nullifier is unspent).
Without revealing which commitment it is. The on-chain program verifies the proof, burns the nullifier, and releases the tokens to the destination ETA or ATA.

The Four Note Variants

A note is parameterised on two axes — unlocker (who can burn it) × source (where the funds came from):
  • Self-burnable from ATAgetATAIntoSelfBurnableStealthPoolNoteCreatorFunction — sender keeps the unlocker; funded from the writer’s ATA. Single tx, no MPC.
  • Self-burnable from ETAgetETAIntoSelfBurnableStealthPoolNoteCreatorFunction — sender keeps the unlocker; funded from the writer’s ETA. Two-tx MPC pipeline.
  • Receiver-burnable from ATAgetATAIntoReceiverBurnableStealthPoolNoteCreatorFunction — unlocker encrypted against the recipient’s userCommitment; funded from the writer’s ATA.
  • Receiver-burnable from ETAgetETAIntoReceiverBurnableStealthPoolNoteCreatorFunction — same recipient model, funded from the writer’s ETA. Two-tx MPC pipeline.
The “self-burnable” pattern is useful when you want to move funds through the pool yourself. The “receiver-burnable” pattern lets you send tokens anonymously — you write for a recipient, and they burn the note without you having direct access.

Registration prerequisites differ by variant

This is one of the most common integration footguns.
  • Self-burnable creates encrypt the unlocker against the sender’s master-seed-derived key. Sender needs only isUserAccountX25519KeyRegistered. Recipient needs nothing.
  • Receiver-burnable creates encrypt against the recipient’s userCommitment. The recipient must have completed all three registration sub-steps on-chain (account init, X25519 key, user commitment). Otherwise the create simulation fails before any tx is broadcast.
Always pre-check the recipient before a receiver-burnable create. Run getUserAccountQuerierFunction on the recipient address; if any flag is missing, either abort with a clear “ask the recipient to register on Umbra” error, or fall back to a self-burnable create (the sender stays the unlocker; recipient needs zero on-chain state — useful for one-shot transfers).
import { getUserAccountQuerierFunction } from "@umbra-privacy/sdk/query";

const query = getUserAccountQuerierFunction({ client });
const r = await query(recipient);

const ready = r.state === "exists"
  && r.data?.isInitialised
  && r.data?.isUserAccountX25519KeyRegistered
  && r.data?.isUserCommitmentRegistered;

if (!ready) {
  // Fall back to a self-burnable note, or ask the recipient to register.
}

Nullifiers: Preventing Double-Spends

Each note has a corresponding nullifier — a deterministic hash derived from its private inputs. When a note is burnt, its nullifier is stored in an on-chain treap (a self-balancing sorted tree). Before allowing a burn, the on-chain program checks that:
  • The nullifier has not been seen before.
  • The ZK proof is valid for a commitment in the current Merkle tree.
This prevents any note from being burnt twice, even if the burn transaction is replayed. The relayer reports NullifierAlreadyBurnt in the failure reason if you re-submit a burn that has already landed — the SDK’s burner factory treats this as success and surfaces a clean “Note already burnt” string.

Ciphertext Discovery

After a note is written, an X25519 + AES-GCM ciphertext is published on-chain (and indexed off-chain). For self-burnable notes the ciphertext is addressed to the writer; for receiver-burnable notes it is addressed to the recipient. The ciphertext payload contains:
  • Amount (8 bytes).
  • Destination address (32 bytes).
  • Generation index (16 bytes).
  • Domain separator identifying the note variant (12 bytes).
The Umbra indexer stores all ciphertexts and serves them for efficient querying. Your X25519 private key is used locally by the scanner to try decrypting each one — successful decryptions are your burnable notes.
Your private key never leaves your device. Decryption happens in the SDK using locally derived X25519 keys.

Anonymity Set Size

The privacy guarantee of the pool depends on how many other notes exist in the same tree at the time you burn. A tree with only one leaf offers no privacy — it’s obvious which commitment is being burnt. In practice:
  • Wait for more users to write notes before burning.
  • Burning into a different address from the write address increases privacy.
  • Combining a burn into an ETA (rather than an ATA) hides the destination amount further.

Trees Fill Up

Each Merkle tree has a maximum of 1,048,576 leaves. When a tree is full, the on-chain write service starts a new tree at the next sequential index. Notes from different trees have separate anonymity sets. The scanner walks every active tree automatically — you do not need to specify a tree index. (The old (treeIndex, start, end) arguments are gone; cursor state lives in client.utxoDataStore and is per-tree under the hood.)