voidcashdocs

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 GET is accepted; any other method, OPTIONS included, 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/activity are 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/status answers 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-origin to the request's origin and varies on origin.
  • from and limit are 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/status
type Status = {
  healthy: boolean;
  leaves: number;
  nullifiers: number;
  root: string | null; // decimal field element
  slot: string;
};
FieldMeaning
healthyWhether the indexer's root, the last time it held every leaf the program has, was in the program's root history
leaves, nullifiersHow many it holds
rootIts tree's root, or null before the first check
slotThe slot of the last transaction it indexed

GET /v1/leaves

Every leaf, in tree order, with its ciphertext.

ParameterDefault
from0The first leaf index
limit5000At 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.

ParameterDefault
from0How many to skip
limit5000At most 5,000
type Nullifiers = string[]; // decimal field elements

Page 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.

ParameterDefault
limit100At 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
}[];
FieldMeaning
kindshield, private or unshield, from the sign of ext_amount. A note merge is a private transaction
lamportsThe SOL in (shield) or out before fees (unshield); null for private
timeThe block time in Unix seconds, or null when the RPC does not report it
commitmentsThe 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
  };
};
FieldMeaning
transactionsCounts per kind, all time
day.countsCounts per kind over the last 24 hours, by block time
day.shielded, day.unshieldedLamports shielded and unshielded over the last 24 hours
day.hourly24 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 Inserted events, ciphertexts and nullifiers from the instruction data, exits from the Exited events. A Program 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.

On this page