Skip to main content

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​

MethodRPCReturns
client.privacy.getPoolStats()privacy_getPoolStats{ merkleRoot, commitmentCount, nullifierCount, totalBalance, assetBalances: [{ assetId, balance }], treeDepth }
client.privacy.getMerkleRoot()privacy_getMerkleRootThe 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 }
  • commitmentCount is the number of leaves ever inserted, across every tree of the forest. nullifierCount is the number of nullifiers spent.
  • totalBalance and each balance are 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.
  • treeDepth is always 20.
  • getSubtreeRoots checks the response against the request before returning it (level, tree, start, count, and that every root is a canonical field element) and throws InvalidSubtreeRootsError when it does not match. How a wallet builds a path from these roots is in Merkle Forest.

Nullifiers, roots and fees​

MethodSourceReturns
client.privacy.getNullifierStatus(nullifierHex)privacy_getNullifierStatus{ nullifier, isSpent }
client.shieldedPool.isKnownRoot(root, { minBlocksLeft })Storagetrue 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()StorageThe runtime's spend-fee floor in planck, as a bigint
client.shieldedPool.pendingFees(account, assetId)StorageFees credited to account and not yet claimed, in planck. account is SS58, an AccountId32 hex or an EVM address

Circuits and validators​

MethodRPCReturns
client.zkVerifier.getAllCircuitVersions()zkVerifier_getAllCircuitVersions[{ circuitId, activeVersion, supportedVersions, vkHashes: [{ version, vkHash }] }]
client.zkVerifier.getCircuitVersionInfo(circuitId)zkVerifier_getCircuitVersionInfoThe same for one circuit, or null for an unknown id
client.chain.isValidator(ss58)chain_isValidatorWhether 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​

CodeWhen
-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​

A per-note query names the note to the node

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.