The indexer
What the indexer stores and serves, how it checks itself against the chain, the alerts it sends on large exits, pauses, parameter changes and upgrades, and its API.
To find its notes, a vault has to try every ciphertext in the pool, and to know which are spent it needs every nullifier. Reading all of that from Solana on every unlock would be slow, so the indexer follows the pool program and serves it in pages. It is a convenience: everything it serves comes from the chain and is checked against the chain, and it can be rebuilt from the chain at any time.
What it stores
The indexer reads the program's transactions from JSON-RPC at confirmed, oldest first after its cursor, and skips
failed ones. From each transact it takes:
| Data | From |
|---|---|
| Leaves: index and commitment | The program's Inserted events |
| Ciphertexts | The instruction data, two per transact |
| Nullifiers | The instruction data, two per transact |
| SOL that left the vault | The Exited events |
| Activity: the kind and, for shields and unshields, the amount | The sign of ext_amount: shield, private or unshield. A private transfer and a merge look the same and carry no amount |
| Block time | The RPC |
A Program data: log line counts only while the pool program itself is executing, so events emitted by any other
program are ignored. The commitments in each event must equal the ones in the instruction, or the indexer stops on
that transaction.
Storage is Postgres: leaves, nullifiers, activity, the indexing cursor and the health status. Each Solana transaction is applied in one SQL transaction, and its leaves must continue the tree exactly.
Every instance serves HTTP from Postgres. The instance that holds a Postgres advisory lock also indexes, polling every 2 seconds; the others keep trying for the lock, so one takes over if the writer stops.
Self-check
The writer keeps the tree in memory. When it has as many leaves as the program, its root must be in the program's root history. If it is not, the writer alerts and marks the indexer unhealthy, and every instance answers 503 instead of serving leaves, nullifiers or activity.
Clients do not rely on that. Before every proof the app makes the same check itself, against the tree account it reads from the chain:
- an indexer that drops, changes or invents a leaf gives a root the program does not hold, and the app proves nothing against it;
- a nullifier the indexer leaves out makes a spent note look unspent; spending it fails at the program, because the nullifier's account already exists.
The indexer serves the same data to every client, and clients download all of it, so their requests do not say which notes are theirs.
Alerts
Every alert goes to the log, and to Telegram and a webhook when they are configured. Exits are public on-chain, so the messages carry no private data.
| Alert | When | Default threshold |
|---|---|---|
| Large exit | One Exited amount above a share of the vault's balance before it | 200 bps (2%) |
| Exits in a window | Exits within a window of slots summing above a share of the vault (the balance after them plus their sum). The window starts over after it fires | 500 bps (5%) over 9,000 slots (about an hour) |
| Pause | Any change of the config's pause flags | |
| Parameters | Any change of the fees, the buyback share or the minimum deposit | |
| Upgrade | The program's ProgramData shows a new deployment slot | |
| Mismatch | The indexer's tree does not match the chain; serving stops |
API
| Path | Result |
|---|---|
GET /v1/status | { healthy, leaves, nullifiers, root, slot }. Served even when unhealthy |
GET /v1/leaves?from=&limit= | [{ index, commitment, ciphertext }], up to 5,000 per page. Commitments as decimal strings, ciphertexts as hex |
GET /v1/nullifiers?from=&limit= | Decimal strings, up to 5,000 per page |
GET /v1/activity?limit= | The latest transactions, newest first, up to 100: [{ signature, slot, time, kind, lamports, commitments }]. lamports is null for private transfers |
GET /v1/stats | { leaves, nullifiers, transactions, day: { counts, shielded, unshielded, hourly } }: counts per kind all time and over the last 24 hours, the day's volumes in lamports, and 24 hourly counts, oldest first |
The app reads leaves and nullifiers page by page, and refuses leaves that arrive out of order. The site's live numbers come from /v1/stats and /v1/activity.
The relayer
Why private transfers and unshields go through a relayer, what it checks, what it can and cannot do, its fee quote, its API, and the buyback crank it runs.
Buyback and burn
How a share of every pool fee buys $VOID on Meteora DAMM v2 and burns it - the median price guard, chunk sizing, the instructions, and why callers are paid nothing.