Digest Sync
When Anon starts for the first time, it needs to catch up with every Railgun shielded-pool event that has ever occurred on each supported chain. On Ethereum mainnet alone that means hundreds of thousands of commitments, nullifiers, and unshield events spanning millions of blocks.
Scanning all of that through an RPC node takes tens of minutes. Digest sync reduces it to seconds.
Digest sync is implemented in Anon's SDK and installed into the Railgun engine at initialization, so every Anon client shares one sync implementation and one wire contract. This page describes that contract. For why the design matters to privacy rather than just to speed, see Wallet Syncing.
The Problem
The Railgun engine maintains a local Merkle tree of encrypted notes. To build that tree from scratch, the engine must process every commitment event since the Railgun contracts were deployed.
The engine's default sync implementation queries a Graph/Subgraph endpoint. On Ethereum, this involves paginating through hundreds of thousands of events — and the client must hold the entire dataset in memory while building the tree. It is slow, fragile under tight memory budgets, and every request carries a client-specific filter.
How Digest Sync Works
The indexer continuously processes on-chain Railgun events and uploads them to a CDN as compressed, split-file chunks. Clients download those chunks instead of querying an indexer.
Installing digest sync overrides the engine's two quick-sync callbacks (quickSyncEvents and quickSyncRailgunTransactionsV2) and disables the engine's built-in Graph cold sync outright — the digest path already performs the full bulk load, so leaving the Graph path armed only bought a failing attempt before every fallthrough. There is no Graph fallback. If the CDN is unreachable, sync does not proceed until it is reachable again.
CDN Layout
v4/{chainId}/
meta.json <- root manifest
chunk-index.json <- per-chunk block/sequence ranges
chunks/
0/
meta.json <- per-chunk metadata (block range, checksums, counts)
commitments.json.gz <- flat array of commitment events
nullifiers.json.gz <- flat array of nullifier events
unshields.json.gz <- flat array of unshield events
transactions.json.gz <- flat array of transaction events
tree-utxo-{n}.bin.gz <- packed merkle tree, present on finalizing chunks
tree-txid-{n}.bin.gz
1/
meta.json
commitments.json.gz
...
tip/
meta.json <- mutable tip metadata
commitments.json.gz
nullifiers.json.gz
unshields.json.gz
transactions.json.gz
tree-utxo-active.bin.gz <- packed boundary tree
tree-txid-active.bin.gz
Live endpoints:
- Root manifest:
https://digest.anon.inc/v4/{chainId}/meta.json - Chunk index:
https://digest.anon.inc/v4/{chainId}/chunk-index.json - Chunk meta:
https://digest.anon.inc/v4/{chainId}/chunks/{id}/meta.json - Event file:
https://digest.anon.inc/v4/{chainId}/chunks/{id}/commitments.json.gz
Each event type is stored as a separate gzip file containing a flat JSON array. This allows selective downloading — a client that only needs nullifiers for spend detection can skip the other files.
Every path above is a function of chain state alone. No request carries a wallet identifier, a viewing key, or any filter derived from one, which is what makes the whole surface uniformly cacheable and non-disclosing. See Privacy.
Cache Tiers
Objects move through three cache tiers as they age:
| Tier | Cache-Control |
Applies to |
|---|---|---|
| Live | public, max-age=15 |
Root manifest, chunk-index.json, everything under tip/ |
| Correction window | public, max-age=300 |
The most recently sealed chunk |
| Immutable | public, max-age=31536000, immutable |
Every older sealed chunk |
The five-minute window on the newest sealed chunk exists so a regression caught shortly after sealing can be corrected with a re-PUT rather than a path-version bump. When the next chunk seals, the previous one is re-stamped immutable in place — the bodies are untouched, only the header changes.
The immutable tier is what makes the CDN absorb load: sealed objects are served from the edge for up to a year, so origin sees roughly one fill per object per location no matter how many wallets sync.
Root Manifest
The root meta.json is intentionally small and near-fixed-size — a few hundred bytes plus the tree-snapshot registry:
{
"version": 5,
"formatVersion": 2,
"chainId": 1,
"txidVersion": "V2_PoseidonMerkle",
"latestSealedChunk": 198,
"latestBlock": 25725624,
"tipChecksum": "sha256_of_tip_meta_json",
"generatedAt": "2026-08-10T15:57:59Z",
"live": {
"txidLeafCount": 125336,
"utxoLeafCount": 252705,
"endBlock": 25725609
},
"treeSnapshot": { /* see Tree Snapshots */ }
}
There is no inline chunk array. latestSealedChunk tells the client how many sealed chunks exist (0 through N). The tipChecksum is the SHA-256 of the tip's meta.json content — the client compares it to detect new tip data. formatVersion gates the wire format — a mismatch between the CDN and the client's cached data triggers a full cache clear and re-download.
live folds the tip's position into the same object as the sealed watermark, so a client reads the sealed and tip halves of the chain's position atomically from one fetch rather than racing two.
Chunk Index
chunk-index.json publishes each sealed chunk's block and sequence range in one small object:
{
"chainId": 1,
"chunks": [
{ "id": 0, "startBlock": 15725039, "endBlock": 16234121, "firstSeq": 0, "lastSeq": 799 }
]
}
firstSeq is -1 for a chunk holding no transactions. The index lets a client locate the chunk covering a given block or sequence in one fetch instead of walking chunk metadata. It is served 404 on chains where it has not been published yet, and clients fall back to walking every chunk.
Per-Chunk Metadata
Each chunk directory contains a meta.json with block range, event counts, and per-file checksums:
{
"chunkId": 0,
"formatVersion": 3,
"startBlock": 15725039,
"endBlock": 16234121,
"counts": { "commitments": 1081, "nullifiers": 1323, "unshields": 595, "transactions": 800 },
"files": {
"commitments": { "checksum": "a1b2c3...", "size": 245000, "jsonSize": 720000 },
"nullifiers": { "checksum": "d4e5f6...", "size": 180000, "jsonSize": 510000 },
"unshields": { "checksum": "g7h8i9...", "size": 195000, "jsonSize": 580000 },
"transactions": { "checksum": "j0k1l2...", "size": 91000, "jsonSize": 260000 }
},
"sealedAt": "2026-03-15T..."
}
Checksums are SHA-256 hashes of the decompressed JSON content. size is the gzipped file size in bytes; jsonSize is the decompressed size.
Chunk metadata and the root manifest carry independent formatVersion counters — a chunk meta at formatVersion: 3 alongside a root manifest at formatVersion: 2 is expected, not a mismatch. Compare each against its own object.
Chunk Sealing
Chunks target roughly 3,000 events across all types. A new chunk is sealed when the tip exceeds ~6,000 events. Chunk boundaries are always aligned to block boundaries — a single block's events are never split across two chunks. Ethereum mainnet is approaching 200 sealed chunks; the L2 chains are considerably smaller.
Client Side
The digest sync functions are passed directly to the Railgun engine during initialization — no global hooks. When the engine requests events for a chain:
-
Fetch the root manifest — one small request gives the number of sealed chunks, the tip checksum, and the live position.
-
Download only what's missing — each chunk is cached individually in the client's local store. Sealed chunks are cached permanently by existence alone. The tip is re-downloaded when
tipChecksumchanges. For each cache-miss chunk, the client fetches itsmeta.jsonthen downloads the event files in parallel. -
Reassemble — the client concatenates all chunks into a single
AccumulatedEventsobject, re-sorts commitments into merkle tree insertion order, and hands it to the engine.
Which chunks a client fetches is determined by block height and by what it has already cached — never by wallet contents. Two wallets at the same sync position issue the same requests.
Cache Correctness
Sealed chunk files are served at immutable URLs, which makes them free to cache and dangerous to correct: after a producer re-sequence the same URL is rewritten with new bytes, and any intermediate cache keeps serving the pre-repair data. A rebuild that re-walks sealed chunks then lands short of tip with no error surfaced anywhere.
Two controls close this:
- Sealed chunk files are fetched with
cache: 'reload'unconditionally. The client skips its own HTTP cache and re-pulls from the CDN. This is free in steady state — a warm client serves the immutable interior from its persisted chunk store and never re-fetches it — and it is path-agnostic, so no recovery routine has to remember to opt in. - Tip files carry a
?v={tipChecksum}cache-key parameter. Because the checksum changes whenever tip content changes, every HTTP cache key from the browser to the edge rotates with the content. Stale entries are never requested again rather than waited out.
The second is a correctness control, not an optimization. Tip files are short-TTL by design, but a single misapplied cache policy anywhere in the chain pins clients to stale data silently and with no error. In August 2026 a CDN browser-TTL override served these objects with max-age=14400, and wallets re-read a superseded tip for hours — including, after the producer had repaired a broken verification-hash chain, the very data that had wedged them. Cache-key rotation makes that class of incident impossible from the client side regardless of how intermediaries are configured.
TXID Transaction Sync
Transaction events drive the accelerated TXID merkle tree sync. Unlike UTXO events (commitments, nullifiers, unshields), which are assembled into a single in-memory object, transactions are streamed to the engine in cursor-driven batches that map directly onto the transactions.json.gz files described in Event File Formats.
The engine repeatedly calls QuickSyncRailgunTransactionsV2(chain, latestGraphID). For each call the SDK:
- Parses
latestGraphIDas a decimalsequencelabel. Missing, empty, or legacy non-numeric cursors start before the first sequence. - Locates the chunk containing the cursor sequence and fetches enough sealed data for a batch, including the current head for verification. It uses full
tip/files while catching up and sequence-keyed recent events once live. - Keeps transactions with
sequence > cursor.sequence, deduplicates overlaps (sealed data wins), sorts bysequence, and validates every verification-hash link. A numeric label gap is crossed only when the hash chain proves no leaf is missing. A broken or unverifiable link stops delivery; the engine receives only the valid prefix up to its batch size.
The engine appends returned transactions in order and resumes with the last transaction's graphID, a decimal sequence string. Sequence labels and canonical leaf positions are different coordinate systems; do not use the cursor as a tree index.
Prefetching
Clients prefetch during engine initialization — before the engine requests any events. The manifest for each chain is downloaded and all sealed chunks plus the tip are ingested into the local store in parallel. By the time the engine requests events, the data is already cached locally, which makes the sync step nearly instant on repeat launches.
Live Polling
After initial sync, clients poll for new events in two modes:
- API mode (primary, 5-second interval) — queries the indexer's live head API (
/api/v1/indexer/head/{chainId}/status). WhentipBlockchanges, fetches a delta of recent events from/recent. Falls through to a wider delta if the initial response is empty and the local cache is behind. - CDN mode (fallback, 30-second interval) — activated when the API reports
status: "not_configured". Polls the rootmeta.jsonand detects tip changes viatipChecksum.
Both modes trigger a lightweight balance refresh when new events arrive.
Polling pauses automatically after 10 minutes of user inactivity (no mouse, keyboard, scroll, or touch events) and resumes when activity is detected.
API mode is the only part of the sync path that reaches Anon-operated infrastructure, and the only part whose request carries client state — the cursor in ?fromBlock / ?fromSequence is the client's position in the chain. It is a chain coordinate, not a wallet predicate; see Privacy.
API Polling Flow
Each 5-second tick follows this sequence:
- Fetch status —
GET /api/v1/indexer/head/{chainId}/statusreturnstipBlock,cdnBlock,reorgEpoch, and indexer liveness. - Reorg check — if
reorgEpochhas incremented since the last poll, the tick triggers reorg recovery (see below) and skips normal event processing. - First poll — records
tipBlockandcdnBlockas baselines without fetching events. - Change detection — if
tipBlockhasn't changed, the tick is a no-op. - Claim tipBlock — the new
tipBlockis recorded immediately to prevent concurrent in-flight fetches from duplicating work. - Fetch delta —
GET /api/v1/indexer/head/{chainId}/recentreturns events betweencdnBlockandtipBlockas a base64-encoded JSON payload. If the delta contains events, they are passed to the engine. - Wider fallback — if
/recentreturns 0 events (e.g. a chunk seal just promoted events to CDN) and the local event store is behindtipBlock, the poller retries with?fromBlock={clientBlock}to fetch a wider range starting from the client's last known block.
Recent Events Endpoint
GET /api/v1/indexer/head/{chainId}/recent[?fromBlock=N | ?fromSequence=N]
Returns delta events since cdnBlock (or since the supplied cursor):
{
"chainId": "1",
"tipBlock": "24729300",
"cdnBlock": "24729271",
"recentEvents": 3,
"eventsJson": "<base64-encoded JSON>"
}
The eventsJson field decodes to the same schema as CDN tip chunks — arrays of commitments, nullifiers, unshields, and transactions. The client decodes, parses, and hands the events directly to the engine without persisting them to its local event store first; persistence happens fire-and-forget in the background.
Block cursor vs sequence cursor. ?fromBlock suits content-keyed UTXO events but can omit TXID transactions below the block watermark. TXID consumers use ?fromSequence and validate the returned verification-hash chain. Old servers may ignore the sequence parameter and fall back to block filtering, so clients use the complete tip/ data when needed. Numeric label gaps alone do not prove a missing leaf; hash-chain continuity is the acceptance rule.
Reorg Recovery
The indexer tracks blockchain reorganizations and exposes them through the /status endpoint. Clients detect and recover from reorgs at two points:
Detection
The /status response includes three reorg fields:
reorgEpoch— monotonic counter, incremented each time the indexer detects a reorg.reorgForkBlock— the block number where the fork occurred.reorgTxCount— number of Railgun transactions that need to be rolled back from the TXID tree.
The poller compares reorgEpoch against its last-known value each tick. An increment triggers recovery. The epoch is also persisted to local storage so reorgs that occur while the client is closed are detected on next startup.
Recovery Steps
- TXID tree rollback — calls
clearLeavesForInvalidVerificationHash(txCount)on the TXID merkle tree to remove the affected leaves, theninvalidateTXOsCacheAllWallets()to force wallets to re-read from the database. - Event store purge — deletes all locally stored events with
blockNumber > forkBlock. - SyncMeta reset — if
highestBlockexceedsforkBlock, resets it toforkBlockso the next ingest starts from the correct point. - Re-sync — the client re-ingests from the CDN tip and runs the full UTXO → TXID sync pipeline to pick up the corrected chain of events.
The UTXO tree does not need explicit rollback — the engine's merkle root validator self-heals by skipping stale commitments and accepting corrected ones when they arrive in the next sync.
Deferred Reorgs
If a UTXO scan is in-flight when a reorg is detected, executing recovery immediately would mutate the event store while the scan is reading from it. In this case, the reorg is stored as a pending operation. The next poller tick processes the deferred reorg before applying any new events, ensuring the scan completes cleanly before recovery begins.
Desync Recovery
Reorgs are the well-behaved failure. The harder class is a client that is not obviously broken but has stopped converging on tip — a hole in the TXID sequence, leaf data written at positions that later shifted, or a producer re-sequence that rewrote bytes a client already consumed. These surface as a sync that runs forever without advancing rather than as an error.
The SDK detects and repairs them with a small set of independent signals:
- Stuck detection — a sync that keeps running without the cursor advancing is classified rather than retried indefinitely.
- Re-sequence detection — a mismatch between the client's ingested sequence and what the CDN now publishes indicates the producer rewrote history the client already holds.
- No-progress breaker — repeated batches that deliver no new leaves trip a breaker instead of looping.
- Snapshot re-hydrate — on a chain whose trees were built from a tree snapshot, a wedged TXID side is healed by re-adopting the snapshot and re-running the data backfill.
The last one matters because the obvious repair is wrong. A full TXID reset rebuilds from sequence 0 and then demands pre-creation UTXO leaf data that a creation-trimmed snapshot deliberately never wrote — so a "full rebuild" on a snapshot-hydrated chain deterministically reproduces the wedge it was meant to clear. Recovery routes to snapshot re-hydration instead.
Every automatic repair is capped at one run per chain per session, consumed before the run starts so a failing run still counts, and escalates to a manual path rather than re-arming itself.
Event File Formats
Chunks and the tip share one wire format. Each chunk directory and the tip/ directory contain the same four gzipped files, each a flat JSON array of one event type:
| File | Array element | Engine use |
|---|---|---|
commitments.json.gz |
commitment batch event | UTXO merkle tree leaves |
nullifiers.json.gz |
nullifier event | spend detection |
unshields.json.gz |
unshield event | withdrawal records |
transactions.json.gz |
Railgun transaction | TXID merkle tree leaves |
A tip file and a sealed-chunk file with the same element are byte-compatible — the only differences between chunks and tip are the metadata wrapper and the lifecycle (see Chunks vs Tip). A consumer parses both with identical code.
Encoding Conventions
- Hashes / field elements are fixed 64-char (32-byte) zero-padded hex. The
0xprefix is field-specific — see the table below. Get this wrong and Poseidon hashes won't match. - Big integers that can exceed 2^53 (
value,amount,fee,tokenSubID) are decimal strings, not numbers. - Block numbers, timestamps, tree/index positions are JSON numbers.
- Addresses (
toAddress,tokenAddress) are0x-prefixed EIP-55 checksummed. tokenType:0= ERC20,1= ERC721,2= ERC1155.
| Field | 0x prefix? |
|---|---|
transaction.commitments[], transaction.nullifiers[] |
yes |
transaction.txid / boundParamsHash / verificationHash / railgunTxid |
no |
commitment.hash / txid / npk / shieldKey / encryptedBundle[] / encryptedRandom[] |
no |
preImage.value |
yes |
TransactCommitmentV2 ciphertext.memo |
yes |
TransactCommitmentV2 ciphertext.iv / tag / data[] / annotationData / viewing keys |
no |
nullifier.nullifier / nullifier.txid |
no |
unshield.txid |
no |
| addresses | yes (EIP-55) |
transactions.json.gz
The TXID-tree feed. Each element is one inner Railgun transaction. A single on-chain tx with multiple inner transactions produces multiple entries sharing txid but with distinct sequence, commitments, nullifiers, boundParamsHash, railgunTxid, and utxoBatchStartPositionOut.
{
"version": 2,
"sequence": 54307,
"commitments": ["0x1b02b669258e874b2a3d3e2c58e5b4ee7f38dbb8470766a89a119db47eaf4d9b"],
"nullifiers": ["0x298937ed3be6af8bf16ef3e0a70212e1012ce3902a15bfe2f23eae05df3f27ec"],
"boundParamsHash": "129bda4babbc749540d03d7c4514c5ecda06009a984ee7dbf653e6ca4fb762ac",
"blockNumber": 449432836,
"txid": "63459180506a7477924d1217245f967f22c32a2dbb0094c374a4d66eb236be5d",
"utxoTreeIn": 1,
"utxoTreeOut": 1,
"utxoBatchStartPositionOut": 35669,
"timestamp": 1775426303,
"verificationHash": "2395448c1a31f6b4e018c213f27801421f13d71d5bd7c94d7fbbe30a13c3b15f",
"railgunTxid": "2a79bc02e174d1ff5b6675b04abe3fde748d47fc40628de8fa1d21e9d7c09137",
"unshield": {
"tokenData": { "tokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "tokenType": 0, "tokenSubID": "0" },
"toAddress": "0x5aD95C537b002770a39dea342c4bb2b68B1497aA",
"value": "1000000000000000"
}
}
| Field | Type | Notes |
|---|---|---|
version |
number | Always 2 (V2_PoseidonMerkle). |
sequence |
number | Per-chain monotonic ordering key. See Ordering & the sequence invariant. |
commitments |
string[] | 0x-prefixed 32-byte hex. |
nullifiers |
string[] | 0x-prefixed 32-byte hex. |
boundParamsHash |
string | 32-byte hex, no prefix. |
blockNumber |
number | |
txid |
string | On-chain tx hash, 32-byte hex, no prefix. |
utxoTreeIn / utxoTreeOut |
number | Input / output UTXO tree index. |
utxoBatchStartPositionOut |
number | Start position of this tx's commitments in the output tree. |
timestamp |
number | Unix seconds. |
verificationHash |
string | Rolling hash chain over all transactions, 32-byte hex, no prefix. |
railgunTxid |
string | poseidon(nullifiers, commitments, boundParamsHash), no prefix. Computed by the indexer; omitted if empty. |
unshield |
object | Present only on unshield transactions. |
commitments.json.gz
Array of batch events. Each event groups every commitment emitted by one on-chain log at consecutive tree positions starting at startPosition.
{
"txid": "63459180506a7477924d1217245f967f22c32a2dbb0094c374a4d66eb236be5d",
"treeNumber": 1,
"startPosition": 35669,
"blockNumber": 449432836,
"commitments": [ /* 1+ commitment objects, see types below */ ]
}
Every commitment object carries commitmentType, txid, timestamp, hash, blockNumber, utxoTree, utxoIndex, plus type-specific fields:
ShieldCommitment (type 2) — a deposit into the pool:
{
"commitmentType": "ShieldCommitment",
"txid": "…", "timestamp": 1775426303, "hash": "…", "blockNumber": 449432836, "utxoTree": 1, "utxoIndex": 35669,
"preImage": { "npk": "…", "token": { "tokenAddress": "0x82aF…", "tokenType": 0, "tokenSubID": "0" }, "value": "0x…" },
"encryptedBundle": ["…", "…", "…"],
"shieldKey": "…",
"fee": "10000000000000",
"from": null
}
preImage.value is 0x-prefixed; fee is a decimal string, omitted when zero; from is always null.
TransactCommitmentV2 (type 3) — an output note from a private transfer:
{
"commitmentType": "TransactCommitmentV2",
"txid": "…", "timestamp": 1775426303, "hash": "…", "blockNumber": 449432836, "utxoTree": 1, "utxoIndex": 35670,
"ciphertext": {
"ciphertext": { "iv": "…(16-byte hex)", "tag": "…(16-byte hex)", "data": ["…", "…"] },
"blindedReceiverViewingKey": "…",
"blindedSenderViewingKey": "…",
"memo": "0x…",
"annotationData": "…"
},
"railgunTxid": "2a79bc02…"
}
ciphertext.memo is 0x-prefixed; iv/tag/data[]/annotationData/viewing keys are not. railgunTxid is null when the commitment has no matched transaction.
LegacyGeneratedCommitment (type 0) — same shape as ShieldCommitment but with encryptedRandom: [string, string] instead of encryptedBundle/shieldKey/fee/from.
LegacyEncryptedCommitment (type 1) — like TransactCommitmentV2 but the ciphertext uses legacy fields: ciphertext.ephemeralKeys: string[] and ciphertext.memo: string[] (an array, not a 0x-prefixed string).
Legacy commitment types only appear in early historical chunks (pre-V2 deployment on Ethereum). New chains contain only
ShieldCommitmentandTransactCommitmentV2, but a complete consumer must still parse all four.
nullifiers.json.gz
{
"nullifier": "298937ed3be6af8bf16ef3e0a70212e1012ce3902a15bfe2f23eae05df3f27ec",
"treeNumber": 1,
"txid": "63459180506a7477924d1217245f967f22c32a2dbb0094c374a4d66eb236be5d",
"blockNumber": 449432836
}
nullifier and txid are 32-byte hex, no prefix. Globally unique by nullifier.
unshields.json.gz
{
"txid": "cb4293e3a81241ef8b6c48285e4860222d9c147da8583aa1d53a7117f230c368",
"timestamp": 1676326168,
"toAddress": "0x5aD95C537b002770a39dea342c4bb2b68B1497aA",
"tokenType": 0,
"tokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
"tokenSubID": "0",
"amount": "1000000000000000",
"fee": "10000000000000",
"blockNumber": 60674448,
"eventLogIndex": 5,
"railgunTxid": "2a79bc02…",
"poisPerList": null
}
amount/fee/tokenSubID are decimal strings. timestamp, eventLogIndex, railgunTxid, and poisPerList may be null. Unique by (txid, eventLogIndex). poisPerList is currently always null.
Chunks vs Tip
The element files are identical in shape; only the metadata wrapper and lifecycle differ.
| Sealed chunk | Tip | |
|---|---|---|
| Path | chunks/{id}/ |
tip/ |
| Metadata | chunkId, startBlock, endBlock, counts, files, sealedAt |
startBlock, endBlock, counts, files, generatedAt (no chunkId) |
| Mutability | Immutable once promoted | Rewritten on every indexer flush |
| Cache-Control | max-age=300 while newest, then max-age=31536000, immutable |
max-age=15 |
| Change detection | existence of chunk id (latestSealedChunk) |
tipChecksum in root manifest |
tip/meta.json:
{
"formatVersion": 2,
"startBlock": 449762846,
"endBlock": 449765001,
"counts": { "commitments": 412, "nullifiers": 388, "unshields": 21, "transactions": 140 },
"files": {
"commitments": { "checksum": "…", "size": 12345, "jsonSize": 45678 },
"nullifiers": { "checksum": "…", "size": 9876, "jsonSize": 23456 },
"unshields": { "checksum": "…", "size": 1234, "jsonSize": 4567 },
"transactions": { "checksum": "…", "size": 5678, "jsonSize": 12345 }
},
"generatedAt": "2026-04-06T12:34:56Z"
}
The tip is the live, unsealed window. When it grows past the seal threshold (~6,000 events) the indexer carves its oldest block-aligned ~3,000 events into a new sealed chunk and shrinks the tip accordingly. The same events therefore migrate from tip/ into chunks/{id}/ over time, but their content (including each transaction's sequence) is stable across that migration.
Ordering & the sequence invariant
This is the contract a TXID-tree consumer depends on:
transactionsare sorted ascending by(blockNumber, sequence)within every file — sealed chunk and tip alike. The other three event types are keyed by content (commitment position, nullifier,(txid, logIndex)), so consumers should not depend on their array order; commitments are re-sorted into(treeNumber, startPosition)merkle-insertion order by the engine.sequenceis a per-chain ordering label, not a guaranteed dense index. Producer corrections can leave absent labels or duplicate ranges. Consumers deduplicate by label and bridge a gap only after verification-hash validation; they must not silently skip a missing leaf.- The engine's flat TXID index (
tree * TREE_MAX_ITEMS + index) comes from append position. It can differ fromsequence. Keep label-space cursors separate from index-space values such as validated TXID indexes and tree lengths; translate using the actual head relationship when comparing them. - The resume
graphIDis the decimal string of the last acceptedsequence. Locate the cursor's chunk, validate its head, discard already accepted labels, and ingest the verified suffix in order. Missing or legacy non-numeric cursors are cold starts.
Tree Snapshots
Alongside the event stream, the producer publishes packed merkle trees — the trees themselves, not the events needed to rebuild them. They are ordinary CDN objects on the same uniform, wallet-independent paths as everything else.
Artifacts are gzipped packed binaries:
chunks/{id}/tree-utxo-{n}.bin.gzandchunks/{id}/tree-txid-{n}.bin.gz— a tree that reached its final leaf count, written once at the chunk that finalized it and immutable thereafter.tip/tree-utxo-active.bin.gzandtip/tree-txid-active.bin.gz— the current boundary tree, covering sealed data only.
The treeSnapshot object in the root manifest is the registry that locates and validates them:
{
"sealedEndBlock": 25684485,
"chunkId": 198,
"treeDepth": 16,
"finalized": {
"utxo": [
{ "tree": 0, "chunkId": 53, "leafCount": 65536, "finalizedAtBlock": 21332114, "root": "02854cff…" },
{ "tree": 1, "chunkId": 106, "leafCount": 65535, "finalizedAtBlock": 23461913, "root": "23699538…" }
],
"txid": [
{ "tree": 0, "chunkId": 107, "leafCount": 65536, "finalizedAtBlock": 23481977, "root": "21efb8c0…" }
]
},
"active": {
"utxo": { "tree": 3, "leafCount": 54733, "root": "2885ae14…", "checksum": "8f68e1ac…", "bytes": 3504154 },
"txid": { "tree": 1, "leafCount": 58775, "root": "108cf0ac…", "checksum": "fa4c125d…", "bytes": 3762975 }
},
"txidState": { "leafCount": 124311, "cursorSeq": 124310, "verificationHash": "30c7d5e1…" }
}
| Field | Notes |
|---|---|
treeDepth |
Leaf capacity exponent — trees hold 2treeDepth leaves. Protocol-controlled and subject to change with notice; always read it, never hardcode 16 or 65536. |
finalized[].leafCount |
The tree's final leaf count. Usually 2treeDepth, but Railgun finalizes a tree short when a commitment batch does not fit the remaining space — mainnet UTXO tree 1 finalized at 65,535. |
finalized[].finalizedAtBlock |
Block of (or an at-or-after bound on) the completing leaf. A wallet created after this block owns nothing in the tree, so its leaf data can be skipped while the tree's nodes still hydrate. |
finalized[].root / active.root |
64-char un-prefixed hex. Verify the artifact's trailing record equals this. |
active.checksum |
SHA-256 of the gzipped tip artifact as served. |
txidState |
Cursor state at the snapshot boundary: global leaf count across all TXID trees, the sequence label of the last sealed leaf, and the verification hash. Sequence labels can gap; leaf order is ordinal. |
Two properties are worth stating precisely because they cause bugs when assumed away. leafCount is not always a power of two. And cursorSeq is a label, not a position — a consumer that treats it as an index will drift.
Cold Start vs Warm Start
| Scenario | What happens | Typical time |
|---|---|---|
| Cold start (first install) | Downloads all sealed chunks (approaching 200 on Ethereum, fewer on L2s) plus the tip, in parallel | 5–15 seconds |
| Warm start (returning user) | Checks manifest, downloads only updated tip files | < 1 second |
| CDN unreachable | Sync does not progress; the client surfaces indexer health and retries | — |
There is no Graph fallback. The engine's built-in Graph cold sync is disabled when digest sync is installed, so an unreachable CDN stalls sync rather than silently switching to a slower, query-based path.
Integrity
Per-file checksums in chunk metadata are SHA-256 hashes of the uncompressed JSON. Tree-snapshot artifacts are checksummed over the gzipped bytes as served, and each carries its root as a trailing record for direct comparison against the registry. Tip chunks are validated by comparing tipChecksum from the root manifest. A formatVersion mismatch between the CDN and local cache triggers a full clear and re-download.
Sealed chunk content is stable once promoted out of the five-minute correction window, but stability is a producer property, not a client assumption: clients re-fetch sealed files from origin rather than trusting an intermediate cache, precisely so a correction is never invisible. See Cache Correctness.
Because every artifact is public and immutably addressed, integrity is independently verifiable. Anyone can fetch a chunk, hash it against the published checksum, and reconcile its events against on-chain logs or any other indexer — no access to Anon infrastructure required, and no need to trust the CDN for anything but availability.
Producer Architecture
The digest is produced by two systems:
- Digest generator (TypeScript, one-time seed) — fetches full event history from an upstream indexer in bounded rounds, seals chunks, and uploads to R2. Used once to populate the CDN for a new chain. Runs as a manual job.
- Indexer (Go, steady-state) — bootstraps sealed chunks from CDN on startup, then continuously generates tip data from live block processing. Seals new chunks when the tip exceeds ~6,000 events, promotes the previous chunk to immutable, and publishes tree snapshots as trees finalize. Uploads to R2 with 15-second throttling.
The upstream indexer is a dependency of the producer, not of the wallet. Clients never query it — that is the whole point of the design. It is also read separately as a health signal, so a chain that is behind can be attributed to the upstream rather than to Anon's own indexer.
Privacy
Digest sync is a privacy mechanism, not only a performance one. Wallet Syncing covers the argument for a general audience; this is the protocol-level statement.
Requests are content-independent. Every path under digest.anon.inc is derived from chain state — a chunk id, a block range, a tip checksum. None of it is derived from a viewing key, a wallet address, or note contents. Two clients at the same sync position issue byte-identical request sequences regardless of what they hold. There is no filter for a server to parse, so there is no query to log, retain, or be compelled to produce.
Match and miss are indistinguishable to the server. Trial decryption happens on-device after the bytes land. The edge cannot tell whether a chunk contained anything for a given client, because that determination is made nowhere but on the client.
Uniformity is a design constraint, not an emergent property. It holds only while chunk selection is independent of wallet contents. A client that fetched "just the ranges it needs" based on what it owned would have reconstructed the query in a slower form. Chunk boundaries are therefore functions of block height alone, and range requests are not used to sub-select within a file.
What remains exposed. Stated plainly rather than left for someone else to find:
- The CDN sees IP, timing, volume, and TLS characteristics. The plaintext it serves is public, which is what makes this acceptable — but a cold sync and a tail sync have different traffic shapes, so an observer learns approximately "this address last synced around block X." Not which notes are whose.
- The head API reaches Anon-operated infrastructure and carries a cursor.
?fromBlock/?fromSequenceis the client's chain position. It reveals how far behind a client is, not what it holds. - The PPOI path carries wallet-derived data. Proof of Innocence requires asking an aggregator about specific blinded commitments, and those are derived from the viewing key. This is a genuine per-wallet query over a surface Anon operates. The current proxy can cache these reads and retain request parameters, creating retention and correlation concerns — see PPOI Proxy. The bulk history path is query-free; the compliance path is not, so they need separate privacy analysis.
A VPN or Tor changes which parties observe network traffic; it does not erase its timing and volume. Routing alone does not remove the wallet-derived data in PPOI requests; operators must review caching, retention, and access controls. That distinction is the substantive difference between this design and a query-based indexer, where the disclosure sits at the application layer and no amount of network anonymity touches it.
This is not private information retrieval. No cryptography conceals the request, because the request conceals nothing worth concealing. The cost is bandwidth: clients download the anonymity set rather than a filtered slice of it. That cost is bounded by compression, chunking, and edge caching, and it is paid once per object per edge location rather than once per user — which is also why the design scales with users far better than per-client query serving does.
Supported Chains
Digests are produced for every chain Railgun supports: Ethereum (1), Arbitrum (42161), Polygon (137), BSC (56). Ethereum mainnet has the largest dataset. L2 chains are significantly smaller and sync faster.