Overview
After a Stealth Pool Note is written, the SDK publishes an X25519-AES ciphertext on-chain addressed either to the sender (self-burnable) or to the recipient (receiver-burnable). To find your burnable notes, the scanner:
- Discovers every active stealth-pool tree.
- Loads scan progress from
client.utxoDataStore and computes the unscanned ranges.
- Fetches the encrypted note data for those ranges from the indexer.
- Tries to decrypt each ciphertext with your X25519 + viewing keys.
- Persists progress.
- Returns the successful decryptions, grouped by
(kind, source).
The V18 scanner is zero-arg — the cursor is fully SDK-managed.
The scanner requires the indexer. Ensure indexerApiEndpoint is set when constructing the client. The browser scanner is several orders of magnitude faster when utxoDataStore and nullifierStore are wired — without them, every call re-scans every active tree from genesis.
Usage
import { getBurnableStealthPoolNoteScannerFunction } from "@umbra-privacy/sdk/burn";
const scan = getBurnableStealthPoolNoteScannerFunction({ client });
const result = await scan(); // zero-arg in V18
Return Value
interface ScannedStealthPoolNoteResult {
readonly etaToStealthPoolSelfBurnable: readonly DecryptedStealthPoolNoteData[];
readonly etaToStealthPoolReceiverBurnable: readonly DecryptedStealthPoolNoteData[];
readonly ataToStealthPoolSelfBurnable: readonly DecryptedStealthPoolNoteData[];
readonly ataToStealthPoolReceiverBurnable: readonly DecryptedStealthPoolNoteData[];
readonly scannedTrees: readonly ScannedTreeProgress[];
}
interface ScannedTreeProgress {
readonly treeIndex: U128;
readonly scannedRange: { start: U32; end: U32 } | null;
readonly totalLeaves: U32;
readonly fullyScanned: boolean;
}
The four note buckets are exhaustive — every burnable note maps onto exactly one. Within each bucket, the notes are not Merkle-proof-bundled: proofs are fetched per batch at burn time so a single proof set is shared across an entire burn batch (much cheaper than fetching one proof per note).
Example: Run the scanner
import { getBurnableStealthPoolNoteScannerFunction } from "@umbra-privacy/sdk/burn";
const scan = getBurnableStealthPoolNoteScannerFunction({ client });
const result = await scan();
console.log("Self-burnable from ETA: ", result.etaToStealthPoolSelfBurnable.length);
console.log("Receiver-burnable from ETA: ", result.etaToStealthPoolReceiverBurnable.length);
console.log("Self-burnable from ATA: ", result.ataToStealthPoolSelfBurnable.length);
console.log("Receiver-burnable from ATA: ", result.ataToStealthPoolReceiverBurnable.length);
for (const tree of result.scannedTrees) {
console.log(
`Tree ${tree.treeIndex}: scanned ${tree.scannedRange?.start}–${tree.scannedRange?.end} of ${tree.totalLeaves}${tree.fullyScanned ? " (full)" : ""}`,
);
}
Idempotent — call as often as you like
The scanner is idempotent: subsequent calls only fetch and decrypt new leaves since the last call. The cursor lives in client.utxoDataStore.getScanProgress(treeIndex) / client.utxoDataStore.addScannedRange(treeIndex, start, end). Re-mounting the scanner factory does not reset state.
A typical browser app calls the scanner on every page load and on a 30s timer while the page is open.
How decryption works
The SDK derives your X25519 private key from the master seed, then for each note ciphertext:
- Extracts the writer’s ephemeral X25519 public key from the ciphertext header.
- Computes an X25519 ECDH shared secret.
- Derives an AES-GCM key.
- Attempts to decrypt the payload.
- If decryption succeeds, reads the 12-byte domain separator and routes the note into the matching
(kind, source) bucket.
Your private key never leaves your device.
Custom indexer fetchers
If you are running a private indexer mirror, override the data fetchers via deps:
const scan = getBurnableStealthPoolNoteScannerFunction(
{ client },
{
fetchStealthPoolNoteData: async (treeIndex, range) => mirror.fetchNotes(treeIndex, range),
fetchMerkleProof: async (treeIndex, leafIndex) => mirror.fetchProof(treeIndex, leafIndex),
// Multi-key wallets can scan with extra viewing keys:
additionalX25519PrivateKeys: [otherPrivKey],
},
);
(The burner factory’s per-batch fetchBatchMerkleProof is a separate dep on the burner — see Burning.)
Error Handling
import { isFetchUtxosError } from "@umbra-privacy/sdk/errors";
try {
const result = await scan();
} catch (err) {
if (isFetchUtxosError(err)) {
switch (err.stage) {
case "initialization":
// The scanner was constructed without an indexer endpoint.
console.error("Indexer not configured:", err.message);
break;
case "key-derivation":
console.error("Viewing-key derivation failed:", err.message);
break;
case "indexer-fetch":
// The indexer was unreachable or returned an error.
console.error("Indexer fetch failed:", err.message);
break;
case "proof-fetch":
// The burner's per-batch Merkle proof fetch failed.
console.error("Merkle proof fetch failed:", err.message);
break;
case "proof-enrichment":
// enrichWithMerkleProof failed to attach a proof entry to a scanned note.
console.error("Proof enrichment failed:", err.message);
break;
}
} else {
throw err;
}
}
An empty result (all buckets empty) is not an error — it means no notes addressable by your viewing keys exist in the trees the scanner walked. Errors are reserved for infrastructure failures.
Burn the result
Pass the scanned buckets directly to the matching burner factory — the burner fetches per-batch proofs internally:
import {
getReceiverBurnableStealthPoolNoteIntoETABurnerFunction,
getSelfBurnableStealthPoolNoteIntoETABurnerFunction,
getSelfBurnableStealthPoolNoteIntoATABurnerFunction,
} from "@umbra-privacy/sdk/burn";
// Burner factory setup omitted — see /sdk/mixer/claiming-utxos.
// Burn all receiver-burnable notes from ATA-source writes into your ETA.
await burnReceiverIntoEta(result.ataToStealthPoolReceiverBurnable);
See Burning for full burner factory setup, batching behaviour, and dropped-callback recovery.