Indexer API
The indexer's HTTP API: every leaf and nullifier for finding notes, and the pool's public activity.
The indexer follows the pool program and serves what clients need to find their notes: every leaf with its
ciphertext, and every nullifier. Clients download all of it, so the indexer never learns which notes are whose. It
also serves the pool's public activity, the same data the chain shows. The examples use <indexer> for its base URL.
Everything it serves can be rebuilt from the chain. Check what you download against the chain before you prove: see Verifying the data.
Conventions
- Only
GETis accepted; any other method,OPTIONSincluded, gets 405{ "error": "method not allowed" }. - Responses are JSON with
cache-control: no-store. - Field elements (commitments, nullifiers, roots) are decimal strings. Ciphertexts and the commitments in
/v1/activityare lowercase hex. - Slots and lamport amounts are decimal strings.
- When the indexer's tree does not match the chain, or before its first check, every endpoint except
/v1/statusanswers 503 with{ "error": "the indexer does not match the chain" }. - Other errors: 404
{ "error": "not found" }, 500{ "error": "internal error" }. - Cross-origin requests are answered only from the origins its operator allows: for those it sets
access-control-allow-originto the request's origin and varies onorigin. fromandlimitare integers; a value below the range is raised to its minimum and one above it lowered to the maximum.
GET /v1/status
curl <indexer>/v1/statustype Status = {
healthy: boolean;
leaves: number;
nullifiers: number;
root: string | null; // decimal field element
slot: string;
};| Field | Meaning |
|---|---|
healthy | Whether the indexer's root, the last time it held every leaf the program has, was in the program's root history |
leaves, nullifiers | How many it holds |
root | Its tree's root, or null before the first check |
slot | The slot of the last transaction it indexed |
GET /v1/leaves
Every leaf, in tree order, with its ciphertext.
| Parameter | Default | |
|---|---|---|
from | 0 | The first leaf index |
limit | 5000 | At most 5,000 |
curl '<indexer>/v1/leaves?from=0&limit=2'type Leaves = {
index: number;
commitment: string; // decimal field element
ciphertext: string | null; // 88 bytes, hex
}[];ciphertext is the 88 bytes from the transact instruction that inserted the leaf, or null. Page through with
from set to the last index plus one, until a page comes back empty.
GET /v1/nullifiers
Every nullifier the pool has recorded, in the order they were indexed, as decimal strings.
| Parameter | Default | |
|---|---|---|
from | 0 | How many to skip |
limit | 5000 | At most 5,000 |
type Nullifiers = string[]; // decimal field elementsPage through with from increased by the length of each page, until a page comes back empty.
GET /v1/activity
The latest pool transactions, newest first. A transaction with several transact instructions has one entry per
instruction.
| Parameter | Default | |
|---|---|---|
limit | 100 | At most 100 |
type Activity = {
signature: string; // base58
slot: string;
time: number | null; // Unix seconds
kind: 'shield' | 'private' | 'unshield';
lamports: string | null;
commitments: [string, string]; // 32 bytes each, hex
}[];| Field | Meaning |
|---|---|
kind | shield, private or unshield, from the sign of ext_amount. A note merge is a private transaction |
lamports | The SOL in (shield) or out before fees (unshield); null for private |
time | The block time in Unix seconds, or null when the RPC does not report it |
commitments | The two commitments the transaction inserted |
GET /v1/stats
Counts and volumes for the landing's live numbers.
type Counts = { shield: number; private: number; unshield: number };
type Stats = {
leaves: number;
nullifiers: number;
transactions: Counts;
day: {
counts: Counts;
shielded: string; // lamports
unshielded: string; // lamports
hourly: number[]; // 24 entries
};
};| Field | Meaning |
|---|---|
transactions | Counts per kind, all time |
day.counts | Counts per kind over the last 24 hours, by block time |
day.shielded, day.unshielded | Lamports shielded and unshielded over the last 24 hours |
day.hourly | 24 hourly counts of all kinds, oldest first |
Verifying the data
Read every leaf and every nullifier by paging until an empty page, as above. Before proving, rebuild the tree from the leaves and find its root in the program's own root history.
The tree is an append-only Poseidon Merkle tree of depth 26 whose empty leaf is 0, with Poseidon over BN254 using
circomlib's parameters (see The Merkle tree). Insert the commitments in leaf order,
then look for the root in the tree account, the "tree" PDA, whose layout is in
Program: Tree:
// `data` is the tree account's data, read from an RPC; `root` is the root of your rebuilt tree.
function findRootIndex(data: Uint8Array, root: bigint): number {
if (new TextDecoder().decode(data.subarray(0, 8)) !== 'voidtree') throw new Error('not the tree account');
for (let i = 0; i < 192; i++) {
const off = 1720 + i * 40; // a 32-byte big-endian root, then the u64 slot it was recorded in
let x = 0n;
for (const b of data.subarray(off, off + 32)) x = (x << 8n) | BigInt(b);
if (x !== 0n && x === root) return i;
}
return -1; // the indexer's leaves do not match the chain: do not prove against them
}The index it returns is the rootIndex a transact takes.
A dropped or invented leaf gives a root the program does not hold. The Voidcash app accepts a decrypted note only if it recomputes the leaf's commitment, so an altered ciphertext cannot pass as a note. A hidden nullifier makes a spent note look spendable; a spend of it fails on-chain, since its nullifier account exists.
How it indexes
- It reads the program's transactions from JSON-RPC at
confirmed, oldest first after its cursor, and skips failed ones. - Commitments and leaf indices come from the
Insertedevents, ciphertexts and nullifiers from the instruction data, exits from theExitedevents. AProgram data:line counts only while the pool program itself is executing. - Each transaction is applied to Postgres in one SQL transaction, and its leaves must continue the tree exactly.
- Every instance serves HTTP; the one holding a Postgres advisory lock also indexes.
Alerts
By default the indexer alerts its operator on one exit above 2% of the vault, and on exits that sum to more than 5% of the vault within 9,000 slots, about an hour. It also alerts on any change of the pause flags or the parameters, on any program upgrade, and when its tree does not match the chain.