Reading Pool State
A node serves the shielded pool's public state through the privacy_* JSON-RPC methods, and its
fees and circuit versions through storage and the zkVerifier_* methods. @orbinum/protocol wraps
all of them. This page shows both ways to read them, what each call returns and how each one fails.
None of them needs a key.
With @orbinum/protocol
import { CircuitId, OrbinumClient, formatBalance } from '@orbinum/protocol';
const client = await OrbinumClient.connect({ substrateWs: 'wss://rpc-1.testnet.orbinum.io' });
try {
const stats = await client.privacy.getPoolStats();
console.log(`root ${stats.merkleRoot}`);
console.log(`${stats.commitmentCount} leaves, ${stats.nullifierCount} nullifiers spent`);
for (const { assetId, balance } of stats.assetBalances) {
console.log(`asset ${assetId}: ${formatBalance(BigInt(balance))}`);
}
const fee = await client.shieldedPool.minSpendFee();
console.log(`minimum spend fee: ${formatBalance(fee)}`);
const shield = await client.zkVerifier.getCircuitVersionInfo(CircuitId.Shield);
console.log(`shield circuit: active v${shield?.activeVersion}, accepts ${shield?.supportedVersions}`);
const { isSpent } = await client.privacy.getNullifierStatus(`0x${'11'.repeat(32)}`);
console.log(`nullifier spent: ${isSpent}`);
} finally {
client.destroy();
}
Output against the testnet:
root 0x7b32d7c5c65a0a717706cfbd99fa9dcbd96178e3086a9820797fd64574cab712
1008230 leaves, 603974 nullifiers spent
asset 0: 290,970.335916 ORB
minimum spend fee: 0.001 ORB
shield circuit: active v1, accepts 1
nullifier spent: false
Every hash in these responses (roots, commitments, nullifiers, subtree roots) is a field element in
its 32-byte little-endian encoding, written as 0x hex. To compare a commitment you hold as a
number, encode it with commitmentHexOf.
What each call returns
Pool and tree
| Method | RPC | Returns |
|---|---|---|
client.privacy.getPoolStats() | privacy_getPoolStats | { merkleRoot, commitmentCount, nullifierCount, totalBalance, assetBalances: [{ assetId, balance }], treeDepth } |
client.privacy.getMerkleRoot() | privacy_getMerkleRoot | The active tree's root, as a string |
client.privacy.getSubtreeRoots(treeId, start, count) | privacy_getSubtreeRoots | { treeId, level, treeLeaves, sealed, root, start, roots }: up to 4,096 level-6 roots of one tree and the root it anchors to |
client.privacy.getMerkleProof(leafIndex) | privacy_getMerkleProof | { path, leafIndex, treeDepth, treeId, root } |
client.privacy.getMerkleProofByCommitment(commitmentHex) | privacy_getMerkleProofByCommitment | { path, leafIndex, treeDepth, treeId, root } |
commitmentCountis the number of leaves ever inserted, across every tree of the forest.nullifierCountis the number of nullifiers spent.totalBalanceand eachbalanceare decimal strings in planck, because they exceed JavaScript's safe integer range. They are the pool account's holdings, which include spend fees credited to validators and not yet claimed. See Spend Fees.treeDepthis always 20.getSubtreeRootschecks the response against the request before returning it (level, tree, start, count, and that every root is a canonical field element) and throwsInvalidSubtreeRootsErrorwhen it does not match. How a wallet builds a path from these roots is in Merkle Forest.
Nullifiers, roots and fees
| Method | Source | Returns |
|---|---|---|
client.privacy.getNullifierStatus(nullifierHex) | privacy_getNullifierStatus | { nullifier, isSpent } |
client.shieldedPool.isKnownRoot(root, { minBlocksLeft }) | Storage | true when a spend anchored at root would be accepted: the active root, an earlier root with at least minBlocksLeft blocks of retention left, or a sealed tree's final root |
client.shieldedPool.minSpendFee() | Storage | The runtime's spend-fee floor in planck, as a bigint |
client.shieldedPool.pendingFees(account, assetId) | Storage | Fees credited to account and not yet claimed, in planck. account is SS58, an AccountId32 hex or an EVM address |
Circuits and validators
| Method | RPC | Returns |
|---|---|---|
client.zkVerifier.getAllCircuitVersions() | zkVerifier_getAllCircuitVersions | [{ circuitId, activeVersion, supportedVersions, vkHashes: [{ version, vkHash }] }] |
client.zkVerifier.getCircuitVersionInfo(circuitId) | zkVerifier_getCircuitVersionInfo | The same for one circuit, or null for an unknown id |
client.chain.isValidator(ss58) | chain_isValidator | Whether the SS58 address is a validator account of the current session |
Circuit ids are exported as CircuitId: Transfer 1, Unshield 2, Shield 3. supportedVersions
lists the versions the chain accepts; a retired version is absent even when its key is still
stored.
With the raw JSON-RPC
Any JSON-RPC client can call the same methods over HTTP or WebSocket. They are Safe methods, served by the public endpoints.
curl -s https://rpc-1.testnet.orbinum.io \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"privacy_getPoolStats","params":[]}'
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"merkle_root": "0x7b32d7c5c65a0a717706cfbd99fa9dcbd96178e3086a9820797fd64574cab712",
"commitment_count": 1008230,
"nullifier_count": 603974,
"total_balance": "290970335916459984991136",
"asset_balances": [{ "asset_id": 0, "balance": "290970335916459984991136" }],
"tree_depth": 20
}
}
The node answers in snake_case (commitment_count, is_spent, leaf_index, tree_id);
@orbinum/protocol maps each field to camelCase. Parameter types and every response field are
listed in Public RPC Node.
Every privacy_* method reads the node's best block, which is not yet final. A backend that
must act only on final state reads the pool from finalized blocks' events instead; see
Indexing Events.
Errors
| Code | When |
|---|---|
-32602 (invalid params) | A commitment or nullifier that is not 32 bytes of hex; a leaf index at or past the number of leaves; an unknown tree_id; a commitment the pool does not hold (commitment 0x… not found in the Merkle tree) |
-32603 (internal) | privacy_getPoolStats and both Merkle proof methods on an empty pool (Shielded pool has no commitments) |
-32009 (server busy) | The Merkle proof and subtree-root methods only: at most half the node's cores compute them at once, further requests wait in a queue of 128 for up to 2 seconds, and past that the node refuses them (Merkle proof queue is full, try again later) |
With @orbinum/protocol an RPC error rejects the promise with an error carrying code and
message. getMerkleProof, getMerkleProofByCommitment and getSubtreeRoots retry a busy refusal
up to four times with backoff before rejecting; isServerBusyError(error) identifies one. A busy
refusal says nothing about the note: never read it as "not found".
What a query reveals
Asking whether one nullifier is spent, or for the Merkle proof of one commitment or leaf, tells the node which note you care about. The node can log it with your IP address. Prefer reads that are the same for every caller: pool statistics, the active root, a tree's subtree roots, and whole event feeds. A wallet scans the full spent set and builds Merkle paths from subtree roots for this reason; see Note Discovery.
Checking a note a user chose to show you, such as a disclosure key, needs one per-commitment query. That query reveals the commitment the user already revealed to you, and nothing about notes they did not show.
Related
- Public RPC Node — the
privacy_*wire format and the proof queue - @orbinum/protocol — every client module and its methods
- Indexing Events — following finalized blocks and rebuilding leaves
- Merkle Forest — trees, roots, retention and local paths
- Spend Fees — the fee floor and pending fees
- Network & Tooling — endpoints and rate limits