Skip to content

Latest commit

 

History

History
1831 lines (1574 loc) · 119 KB

File metadata and controls

1831 lines (1574 loc) · 119 KB

JSON-RPC reference

The node serves JSON-RPC 2.0 over HTTP on --rpc (default 127.0.0.1:8545).

curl -s http://127.0.0.1:8545 -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"rand_getHead","params":[]}'

The same port also serves a WebSocket on / and /ws, which pushes every committed head instead of being polled for it — see Subscriptions.

Conventions:

  • Validator addresses are base58 strings (32 bytes). Hashes are 64 hex characters, with or without 0x. Shielded values (commitments, nullifiers, anchors, tree roots, witness levels) are Word8 — eight little-endian u32 words as 64 lowercase hex characters.
  • Shielded addresses are rand1 + base58, about 1668 characters. A parameter longer than 2000 characters is refused on its length before it is parsed.
  • One rule, no exception: every u64 amount — RAND units or token units, chain state or a decoded transaction's own field — is a decimal string ("1500000000" = 1.5 RAND; 1 RAND = 10^9 units), because a JSON number is not an exact integer past 2^53 and a 9-decimal token's supply already passes it at a few tens of millions of units. Indices, nonces, heights, leaf indices, views, lengths, decimals and day counters (mint_day) are never amounts and are plain JSON integers throughout. Concretely, every one of these is a string: rand_getTokens/ rand_getToken/rand_getTokenSupply's supplies and each backing's locked, mint_cap_per_day and minted_today; rand_getAssets's and rand_getBridgeState.assets[]'s matching rows and both methods' registration_fee; a decoded transaction's bundle.fee, burn_a and burn_r; and action.amount for mint, bond, unbond, withdraw, bridge_attest, bridge_burn (plus its relayer_fee), token_mint, token_burn and register_token (initial_amount/ initial.amount). Breaking change, 2026-09-20 (v0.5 / chain 14): the pre-chain-14 fields in that list — bundle.fee; action.amount for mint, bond, unbond, withdraw, bridge_attest; action.amount and action.relayer_fee for bridge_burn; and rand_getAssets's / rand_getBridgeState.assets[]'s locked — used to be JSON integers; see the changelog below for the full list and why it changed.
  • Heights, leaf indices and views are JSON integers.
  • Every request must carry an id member, batched or not: an object without one is a JSON-RPC notification, and this node refuses it with -32600 rather than running it silently. An explicit "id": null is a normal request. See Batches for why.
  • randprotocol_client::RpcClient (Rust) wraps every method below.

There is no balance method, and no account method. This chain has no accounts; see docs/shielded.md. A wallet computes its own balance by scanning the commitment tree with its viewing key, which is what rand_getCommitments and rand_getNullifiers exist for. That holds for bridged assets too: a bridged holding is a note whose asset word is the registry's index for it (phase S3, docs/bridge.md), so the bridge methods below report the bridge's own public state — guardians, emitters, the asset registry, the outbound burn log — and no per-address balance. rand_getAssetBalance is gone for good.

A node can now hold viewing keys — never spend keys. rand_importViewingKey hands the node a viewing key so it scans on the holder's behalf (the Zcash z_importviewingkey analogue, for explorers). That is the one exception to "the node never holds a key", and it is deliberate: the key arrives in memory only, is capped at 64 per node, dies with the process, and can disclose notes but never move them. It also means the RPC port should be treated as key-bearing once an import has happened: bind it where you would bind a wallet, not to the public internet.

Batches

The body of a POST to / is either one request object or an array of at most 20 of them. A batch answers with an array of the same length, in request order, one response per request — errors included, so a client can correlate by position as well as by id. The requests run one after another, not concurrently: a batch is a request amplifier, and rand_getWitness rebuilds the whole commitment tree per call.

A request object with no id member is a JSON-RPC notification, and this node refuses it with -32600 and "id": null rather than running it silently — inside a batch and as a lone request object alike, since both go through the same path. Every method here either reads, where the answer is the point, or submits, where a silently dropped request is an invisible wallet bug — and refusing keeps the reply array the same length as the request array. An explicit "id": null is a normal request and is answered as always.

An empty array, an array over 20, and a body that is neither an object nor an array each come back as a single -32600 error object with a null id, because there is no per-request id to attach them to. A batch that parses always answers 200, whatever the errors inside it.

The count cap is not the byte cap. The whole body is still bounded by the node's request-body limit, which is sized for a single proof-carrying transaction, and that limit is enforced before the count is ever looked at: a batch of two proof-carrying rand_sendTransaction calls is refused with a 413 and a -32600 body naming the limit, whatever the count cap says. (The bundle-less actions — a faucet mint, an Unbond, a Withdraw — are a few kilobytes each and batch fine; it is the proofs that do not.) Batching proved submissions does not work, and is not what this is for; batching the reads a wallet or explorer makes per page is.

curl -s http://127.0.0.1:8545 -H 'content-type: application/json' \
  -d '[{"jsonrpc":"2.0","id":1,"method":"rand_getHead","params":[]},
       {"jsonrpc":"2.0","id":2,"method":"rand_getTreeInfo","params":[]}]'

Methods

rand_chainId

Params: []. Result: chain id (integer). Transactions must carry this id.

rand_tokenInfo

Params: []. Result: { "symbol": "RAND", "decimals": 9 }.

rand_sendTransaction

Params: [hex] where hex is bincode(Transaction) (as produced by Transaction::encode() in randprotocol-core, or by the rand wallet). Result: the transaction hash.

The node validates against the state at the tip of the chain in the order of docs/shielded.md §5 — size caps, chain id, shape and fee floor, anchor, time, nullifiers and commitments, action checks, the bundle digest, the bundle proof, and for a call its own proof and tier fee — puts it in the mempool, and gossips it. Errors come back as code -32000 with the reason, for example nullifier already spent, anchor is not one of the last 256 roots, time 12 is outside [244, 500], fee 1000000 below minimum 2000000, the bundle's digest is not what its proof published, invalid bundle proof: …, unknown program …, already in mempool, conflicts with a pending transaction over <nullifier>, faucet is disabled on this chain, and for the bridge actions bridge: attestation already consumed, the attestation names a different recipient, the attestation deposits under asset 2, and the transaction names 1, the bundle burns 399, not the 400 the action declares, and for a bundle carrying a burn its action may not carry, the bundle burns asset 2 on an action that burns no token or a burn of 5 is not allowed on this action. A sealed (pruned) bundle's marker-form proof submitted outside sealed-form sync — gossip, RPC, a proposer's trial-apply — is refused PrunedFormOutsideSync, and, deliberately, not cached as a permanent verdict: the marker form hashes to the raw transaction's own id (M1), so caching that refusal under the shared hash would let a fast gossip peer censor the honest transaction network-wide (docs/confidential.md, "Transaction binding").

Acceptance is not commitment: poll rand_getTransaction until it returns a block.

A transaction larger than the chain's block cap (max_block_bytes from rand_getLimits; 4 MiB by default) is refused here with -32000, naming both sizes, before it reaches the mempool. The request body itself is capped at 2 × (2 × max_proof_bytes + 4 × 2048 + max_call_envelope_bytes + 16 384 + 64 KiB) + 256 KiB — 8 867 840 bytes on a default chain, 34 127 872 on a chain-13-sized genesis (8 MiB proofs, 64 KiB call envelopes) — computed from the genesis at startup; a body over it is -32600 naming the limit. (4 × 2048 is the bundle's four envelopes at the envelope cap.)

rand_mint (testnet faucet)

Params: [address] or [address, amount], where address is a rand1… shielded address and amount is a string of units, at most 100000000000 (100 RAND; the default). Result: the mint transaction hash.

Only available when the genesis file has "faucet": true; otherwise error -32000 faucet is disabled on this chain. The node builds the note, seals an envelope to address under a throwaway sender key, signs the Mint with its own validator key and submits it through the normal mempool, so the mint goes through consensus and every node applies it. An observer has no validator key and answers faucet mints are signed by validators; ask a validator node. Poll rand_getTransaction for the commit.

On a chain whose genesis sets staking.faucet_recipients (chain 15) only the listed wallets can be paid: a mint to any other address is refused at admission with the faucet may not mint to this recipient (not in staking.faucet_recipients) (docs/staking.md §2).

The faucet is rate limited per node process: eight mints back to back, refilling at one a second. Past that the call answers faucet is rate limited on this node (8 mints back to back, refilling at 1/s); try again shortly, so a faucet flood cannot fill the pool ahead of a bridge PauseMints, which is also exempt from the pool's mempool full refusal and ordered first in a block, like the other three governance actions.

rand_getCommitments

Params: [from_index] or [from_index, limit]. Result: a page of commitment-tree leaves from leaf from_index, oldest first, at most 1000 rows however large limit is (a missing or null limit asks for the maximum). Page until the reply is short or empty.

[ { "index": 40, "cm": "2a9f…07", "height": 37,
    "envelope": { "kem_ct": "b41c…", "to_receiver": "77e0…", "to_sender": "0c31…", "body": "9dd2…" } } ]

Every leaf and every envelope is served to everyone; only a viewing key tells one wallet's rows from another's.

rand_getNullifiers

Params: [from_height] or [from_height, limit]. Result: every nullifier published from that block height onwards, same 1000-row cap.

[ { "height": 37, "nullifier": "8c04…d1" }, { "height": 41, "nullifier": "12be…9a" } ]

A page can stop inside a height, so a caller pages back to the highest height it saw rather than past it; re-reading rows is harmless.

rand_getCompactBlocks

Params: [from_height, to_height], both required. Result: one row per block in the range, oldest first, carrying everything a light wallet needs to trial-decrypt and track spends — and nothing else: no proofs, no actions, no receipts.

[ { "height": 192, "hash": "63f6…08", "timestamp_ms": 1788000123456,
    "commitments": [],
    "transactions": [
      { "hash": "4f2c…e7",
        "commitments": [ { "index": 40, "cm": "2a9f…07",
          "envelope": { "kem_ct": "…", "to_receiver": "…", "to_sender": "…", "body": "…" } } ],
        "nullifiers": ["8c04…d1", "5e77…20", "03aa…6f", "e19b…42"] } ] } ]

Every bundle-carrying transaction owns four notes and four nullifiers here — its four slots, the dummy slots included, which no reader can tell from real ones (only the example's first commitment row is shown).

One call covers at most 128 blocks (half the 256-block anchor window), counted from from_height; a wider range is clamped, not refused. Past the first block the reply also stops once it has emitted 1000 notes — but the first block of a reply is always served whole, however many notes it holds, so a caller is never stuck behind one fat block. Resume from the last returned height plus one, and page until the reply is empty; a from_height past the head comes back [] rather than an error.

The block-level commitments array holds the leaves of that block that belong to no transaction — the genesis deposits at height 0 — and is empty at every other height. A Withdraw's and a BridgeAttest's deposit notes do appear under their transaction even though the wire does not carry their commitments (the ledger derives them, spec §7); they are appended immediately after that transaction's own notes, which is the order served here.

Errors: -32602 for a backwards range (to_height below from_height) or a missing bound.

On a node started with --prune-history, a range that reaches a height below the floor (genesis excepted) answers error -32010 naming the first such height — pruned: height h is below this node's retention floor f with data: {"floor": f} — ask the archive for it. [0, to] still serves genesis alone when to is 0.

rand_getAnchor

Params: [] for the head, or [height]. Result: { "height": 192, "root": "6b1d…c4" }, or error -32001 for a height with no recorded anchor.

Only block-end roots are anchors, and only the newest 256 are kept. A node that caught up in one sync batch longer than that window holds rows only for the heights the batch covered, so ask for the head — the only anchor a prover should build against anyway.

rand_getWitness

Params: [index]. Result: null past the end of the tree, else the Merkle path of that leaf, leaf-first, exactly 32 levels, with the tree's current root:

{ "index": 40, "root": "6b1d…c4", "path": ["0000…00", "f2a1…3b", "…"] }

The root is the live root, not an anchor: a wallet checks it against the anchor it is proving under and refetches if a leaf was appended in between. This is the most expensive read a node serves (it rebuilds a full depth-32 tree from every stored leaf) and the one request that discloses something about the caller — see docs/shielded.md §6.

rand_getTreeInfo

Params: []. Result: { "next_index": 41, "root": "6b1d…c4", "nullifiers": 12 } — the leaf count (the index the next note will get), the current root, and how many notes have been spent.

rand_importViewingKey

Params: [viewing_key] or [viewing_key, rescan_from_height], where viewing_key is a party viewing key's nk as 64 hex characters and rescan_from_height is the block height to start watching from (default 0, the whole chain). Result:

{ "imported": true, "rescan_from_height": 0, "viewing_keys": 3 }

The Zcash z_importviewingkey analogue (docs/rpc-comparison.md §4), and the one deliberate exception to this chain's "the node never holds a key" property: after this call the node holds viewing_key in memory and trial-decrypts for it — which is what a block explorer runs a node for. What it can never hold is a spend key (the RPC layer has no type for one), so an imported key changes what compromising this process would disclose — the notes that key opens, which its holder can already see — and never what it could spend. Nothing is written to disk: a restart clears every import, and the operator's orchestration re-imports on boot.

Imports are bounded and idempotent:

  • At most 64 keys per node (viewing_keys in the reply is the live count, also in rand_status). The 65th distinct key is -32000; a key already held is a no-op ("imported": false) — in particular it does not restart the scan, so a rescan from an earlier height is a restart plus re-import, not a second call.
  • A rescan_from_height in the future is accepted and simply matches nothing until the chain reaches it.
  • Import itself never scans: the scan is lazy, driven by rand_getViewingNotes.
  • Loopback only. rand_importViewingKey, rand_getViewingNotes and rand_removeViewingKey answer a caller on the loopback interface and refuse anyone else with -32000. Nothing in this RPC authenticates anyone, and the facility is for the node operator's own explorer: without the rule a stranger could fill all 64 slots and lock the operator out until a restart. rand-node run --rpc-viewing-open lifts it, for an operator who has put something that does authenticate in front of the port.

Errors: -32602 for a malformed key, -32000 from a caller that is not on loopback.

rand_removeViewingKey

Params: [viewing_key]. Reply: {"removed": bool, "viewing_keys": n} — removed is false when the node was not holding that key. The import's scan state goes with it, its slot is freed, and the key is zeroised in memory. Loopback only, like the other two.

rand_getViewingNotes

Params: [viewing_key] or [viewing_key, from_index, limit] — from_index pages the matched notes by leaf index (default 0), limit caps the page (default and maximum 1000, as everywhere). Result:

{ "scanned_index": 10041, "next_index": 10041, "complete": true,
  "notes": [
    { "index": 40, "cm": "2a9f…07", "height": 37, "role": "received",
      "note": { "pk": "…", "from": "…", "amount": "1500000000", "asset": 0, "time": 5, "memo": "coffee" },
      "nullifier": "8c04…d1", "spent": false },
    { "index": 43, "cm": "b310…88", "height": 39, "role": "sent",
      "note": { "pk": "…", "from": "…", "amount": "25000000", "asset": 0, "time": 9, "memo": null },
      "nullifier": null, "spent": null }
  ] }

Each call first advances the key's scan by up to 10 000 new leaves (one ML-KEM decapsulation plus up to two AEAD opens each), then serves the page. scanned_index is how far the scan has tried, next_index the tree's size, and complete is true when they meet — a long rescan completes over several calls, so an explorer polls until complete. The cap bounds one request, not the history: the cursor persists between calls.

A row is one matched leaf, in tree order. role is "received" for a note the key owns (the envelope opened as receiver and the note names the key's pk — an envelope anyone can seal to a public address is not proof of ownership) and "sent" for a note the key created for someone else, opened through the outgoing viewing key. A received row carries the note's nullifier (a function of the viewing key) and whether the chain has published it — refreshed on every call; a sent row has neither, because the note is not the key's to nullify. Amounts are strings, as everywhere chain state is served. The note's memo is the sender's encrypted memo (spec 2026-09-26 §2.3) if it opened one — null for no memo, or an envelope this key opened but whose memo field was malformed (a malformed memo never costs the payee the note itself, just the text). A memo can be present on any chain: where the genesis sets no envelope_bytes (chains 14 and 15) the ledger still accepts any note envelope up to 2 048 bytes, so a memo-carrying 1 860-byte envelope from another sender is valid and opens with its memo — wallets only seal one where the chain sets envelope_bytes. A memo is anyone's text: show it as untrusted (see docs/cli.md, "How a memo is shown").

Errors: -32602 for a malformed key or page bound, -32001 for a key this node has not imported.

rand_getProgram

Params: [program_id]. Result: null or { "id", "base_pc", "words_len", "code_hash", "deployed_at", "public_words_len", "public_digest" }. There is no deployer field: a deploy is paid by a bundle, so the chain does not know who deployed it.

public_words_len is the length of the program's deploy-time public input (0 without one), and public_digest is its Word8 hex digest — the value every call's proof is checked against — or null for a program deployed without a public input. The words themselves are rand_getProgramPublic's.

rand_getProgramPublic

Params: [program_id]. Result: the program's deploy-time public words as one hex string, each word as its 4 little-endian bytes (8 hex digits a word, the same byte order as a Word8 digest): [1, 2, 0xdeadbeef] is "0100000002000000efbeadde". "" for a program deployed without a public input, null for an id no program has. A wallet proving a call passes these words to the prover: the proof commits to them and the ledger checks that commitment against public_digest.

rand_getLimits

Params: []. Result: the chain's five call limits and the envelope size, from its genesis:

{ "max_program_words": 4096, "max_proof_bytes": 2097152, "max_block_bytes": 4194304,
  "max_call_envelope_bytes": 18432, "max_program_public_words": 0, "envelope_bytes": null,
  "hardening_v6": false }

Those are the defaults, what a genesis without the fields gets (chain 12). A wallet derives its caps from these instead of hard-coding them: the most words a program may have, the largest proof, the largest transaction (a block's worth), the largest call-input envelope, and the most public words a deploy may carry.

envelope_bytes is null on every genesis without the field (every existing chain), meaning no uniform size is enforced and a wallet seals the legacy 1 348-byte note envelope with no memo. Set to 1860 (spec 2026-09-26 §2.4), it means every note-creating envelope on this chain must be exactly that long — a wallet seals the memo-carrying format instead, and a memo becomes readable by the payee, the sender's own history, and anyone handed that output's per-transaction key. There is no other value yet: validate accepts only 1860 once the field is present.

hardening_v6 is true when the genesis sets the v0.6 switch (docs/deploy.md, "The next cut: hardening_v6"). A wallet then proves a call over the program's public input (empty for most) followed by the transaction's call binding (Transaction::call_binding, INT-4; issue #55 for a program with a public input) instead of the public input alone — the fee bundle's notes chosen first, the call proved second, the bundle last; a chain with the flag refuses the old proof, a chain without it the new one. A node that predates the field answers without it, which a wallet reads as false.

rand_getProgramCode

Params: [program_id]. Result: null or { "base_pc": 0, "words": [u32, ...] } (what the wallet proves against).

rand_getReceipt

Params: [tx_hash]. Result: null until the call is committed, then

{ "tx": "…", "program": "…", "tier": 14, "outputs": [1, 0, 25, 0, 0, 0, 0, 0], "height": 17,
  "index": 0, "h_in": "9c0e…7f", "h_pub": null }

h_in is the proof's public commitment to the call's private inputs (Word8 hex, zkVM M4.1). It discloses nothing on its own — it is a salted digest — and it is what a call-input envelope is sealed against, so a holder needs it to open one (rand_getCallEnvelope) and to check an opened transcript with hash::input_digest(salt, inputs).

h_pub is the digest of the program's deploy-time public input the call's proof was checked against (Word8 hex, the program's public_digest). null means the program was deployed without a public input, and the proof was checked against the digest of the empty public input, public_digest([]). The node does not repeat that constant digest in every receipt.

There is no effect field: effect kind 1 (the program-driven transfer to an account) was deleted with the accounts. A call's outputs are recorded and nothing else moves; value moves only through the bundle that paid for the call.

rand_getCallEnvelope

Params: [tx_hash]. Result: null, or the call's input envelope (spec §6.1) in hex:

{ "tx": "…", "h_in": "9c0e…7f", "kem_ct": "…", "to_sender": "…", "to_auditor": "…", "body": "…" }

h_in is the receipt's, repeated here so one request is enough to open the envelope. body is the call's private input vector and its H_IN salt, sealed under a per-call key with that h_in as associated data; to_sender wraps that key to the caller's outgoing viewing key and kem_ct/to_auditor to the auditor the caller named, both empty strings when there is none. The node holds no key that opens any of it and never looks inside — a viewing key imported for note scanning (rand_importViewingKey) opens note envelopes only; call envelopes are not part of its scan. It is served so that a wallet with the caller's viewing key, a per-call key, or the auditor's key can open it (randprotocol_zkvm::call_envelope) and check the transcript against H_IN. null means the call published no envelope (--no-envelope), the transaction is not a call, or this node has no receipt for that hash.

rand_estimateFee

Params: [spec], one of {"kind":"bundle"}, {"kind":"deploy","words":n,"public_words":m} or {"kind":"call","tier":t,"bytes":b} (t one of 10, 12, 14, 16, 18, 20). Result: the minimum fee in units, as a string. {"kind":"bundle"} is the floor for a plain transfer: 1000000. A deploy of more words than the chain's program cap (4096, or the genesis file's max_program_words) is an invalid-params error (-32602) naming the cap — the same program admission would refuse, so a wallet can ask before it proves.

public_words (optional, default 0) is the deploy's public input length. Public words are paid for per word like code, so the fee is the deploy fee of n + m words. More than the chain's max_program_public_words (0 by default) is -32602, naming the cap, in the same shape. Anything but a non-negative integer (or null) is -32602 as well.

bytes (optional, default 0) is the call's proof length plus its input envelope's length. A call at or under the free allowance (2 097 152 + 18 432 bytes) costs what it did before this field existed; each KiB over it, a partial KiB counting as whole, adds 1000 units. Without bytes the answer is the old one. Anything but a non-negative integer is -32602.

rand_getTransaction

Params: [hash]. Result: null until committed, then:

{
  "height": 192, "index": 0, "block_hash": "63f6…08",
  "tx": {
    "hash": "4f2c…e7", "chain_id": 7,
    "bundle": {
      "anchor": "6b1d…c4",
      "nullifiers": ["8c04…d1", "5e77…20", "03aa…6f", "e19b…42"],
      "commitments": ["2a9f…07", "b310…88", "77c1…0e", "5d20…b3"],
      "fee": "1000000", "burn_a": "0", "burn_r": "0", "burn_asset": 0, "time": 5,
      "proof_len": 1431562, "envelope_len": [1380, 1380, 1380, 1380]
    },
    "action": { "kind": "none" }
  }
}

bundle is null on a mint (a mint carries no bundle). The proof and the envelopes are reported by length only; anyone who wants the bytes can fetch the block.

bundle is the hidden-asset bundle (chain 14): four input slots and four output slots, so always four nullifiers, four commitments and four envelope_len, dummies included. It has no asset field: slots 0–1 carry a private asset and slots 2–3 RAND, and nothing public says which asset slots 0–1 moved. A transfer of RAND and a transfer of any RPL token are both "kind": "none" with burn_a, burn_r and burn_asset all 0 — the same shape, field for field. The three burn fields are the bundle's only public statement about value leaving the pool:

  • burn_a / burn_asset — an amount of the private asset burned, and which asset. Non-zero only on a token_burn or a bridge_burn, where they equal the action's amount and asset.
  • burn_r — RAND burned. Non-zero only on a bond (equal to its amount) and a register_aggregator (the genesis bond).
  • fee — the RAND fee, always from slots 2–3.

Other actions:

  • { "kind": "mint", "cm": "…", "amount": "100000000000", "minter": "<validator base58>" }
  • { "kind": "deploy", "program": "<program id>", "words": 412, "public_words_len": 0 }
  • { "kind": "call", "program": "<program id>", "proof_len": 268123, "input_envelope_len": 1280 }

public_words_len is the length of the deploy-time public input; the words are rand_getProgramPublic's once the deploy commits.

input_envelope_len is the size of the call's encrypted input transcript, or null when the call carries none. Like every other envelope it is reported by length alone: the transcript opens for the caller's viewing key and the auditor, not for whoever is reading the explorer.

The staking (phase S2) and bridge (phase S3) actions:

  • { "kind": "bond", "validator": "<base58>", "amount": "500", "registered": false } — registered is whether this bond carried a first-time registration.
  • { "kind": "unbond", "validator": "<base58>", "amount": "7", "nonce": 2 } — rendered with "bundle": null, as a withdraw is: both are signed by the validator's key, and the register's nonce, not a bundle, is what keeps them from being replayed.
  • { "kind": "withdraw", "validator": "<base58>", "amount": "9", "nonce": 3, "time": 1994 } — the deposit note's blinding and envelope are not rendered. time is the note's time word, which the withdrawing node chose; the note itself is worth amount less the bundle base.
  • { "kind": "bridge_attest", "attestation_len": 520, "recipient": "<shielded address>", "asset": 1, "asset_index": 1, "amount": "1000", "time": 41, "r": "<64 hex>", "commitment": "<64 hex>", "pq_signers": [0, 2] } — the amount and the asset are inside the attestation, so they are decoded out of it; asset_index is what the registry gave that asset, and is the asset word of the deposit note. Both are null for a guardian-set rotation (which deposits nothing) and on a chain whose registry does not name the asset. asset is the index the action names, and on a committed attest it always equals asset_index — admission refuses a transaction where they differ — but it is never null, so the two together say whether this node's registry can resolve the deposit at all. time is the deposit note's own time word, which the action publishes and admission holds to the window a bundle's time gets — the note is derived from it, not from the height the transaction landed at. r is the deposit note's blinding, a field of the action and public like the rest of it — derived since chain 14 from the attestation digest the guardians signed (blake3("rand-deposit-r-1" || mu), docs/bridge.md §5), so a reader can recompute it from the same transaction's attestation bytes — and commitment is the leaf the chain computed from those five fields and appended — null for a rotation. Together they are the whole deposit note, which is what lets its recipient rebuild it without opening the submitter's envelope (docs/bridge.md §8); a transfer's or a withdrawal's blinding is not rendered, because those notes are not public. pq_signers (bridge hardening B3) is the co-signing guardians' indices into rand_getBridgeState's pq_guardians — the signatures themselves are 2 420 bytes each and are not rendered, only which of them signed.
  • { "kind": "bridge_burn", "asset": 2, "amount": "400", "relayer_fee": "100", "to_chain": 5, "token": "cdcd…", "to": "abab…" } — token is the backing being redeemed and to the 32-byte destination address, hex. One bundle carries the whole burn: its burn_asset and burn_a are the action's asset and amount, and its fee pays the bridge fee.
  • { "kind": "rotate_pq_guardians", "new_pq_guardians": ["<1312 bytes hex>", …], "nonce": 0, "pq_signers": [0, 1, 2, 3, 4] } and { "kind": "rotate_pause_key", "new_pause_key": "<1312 bytes hex>", "nonce": 1, "pq_signers": [1, 2, 3, 4, 5] } (v0.5.4, bridge rules v2 — docs/bridge.md §21): the two key rotations, rendered with "bundle": null like a pause. nonce is the bridge's rotation_nonce the rotation spent; pq_signers are indices into the PQ set before the rotation. Refused RulesV2Disabled on a chain without bridge.rules_v2.

The RPL token actions. Every amount here is a decimal string — a token's own units, not RAND's — because a 9-decimal token's supply already passes 2^53 at a few tens of millions of units:

  • { "kind": "token_burn", "asset": 3, "amount": "400" } — a holder burn. Its asset and amount are public by design (they audit the token's total_supply), and the bundle's burn_asset/burn_a repeat them (burn_a is a decimal string too, like every amount in this reply).
  • { "kind": "token_mint", "asset": 3, "amount": "700", "recipient": "<shielded address>", "time": 41, "r": "<64 hex>", "nonce": 0 }, { "kind": "register_token", "name": …, "symbol": …, "decimals": 6, "authority": "none" | "key" | "bridge" | "program", "index": 2, "initial_amount": "5000", "initial": { "amount": "5000", "recipient": "<shielded address>", "time": 40, "r": "<64 hex>" } | null } and { "kind": "set_authority", "asset": 3, "nonce": 1, "new_authority": "<base58>" | null } — a token's registration and mints are public, as a bridge deposit is. Every word of a minted note is here (its from is the chain's fixed MINT_FROM), so a recipient rebuilds the note from these fields with nothing decrypted, whatever envelope the minter published — the wallet's scan does exactly that. The kind strings bridge_attest, token_mint and register_token are what that scan keys on and are pinned by a test.

There is no token_transfer: a token transfer is none, indistinguishable from a RAND payment.

No reply from this method carries the sender, recipient, nonce or amount of a transfer: no such field exists in a stored transfer. The staking and bridge actions above are the deliberate exception — a validator address, an amount and a replay nonce are public in them by design, the way a mint's amount is, because the validator register and the bridge's accounting are public (spec §8). A shielded note's later spend stays private in every case.

On a node started with --prune-history, a transaction whose block was pruned normally answers null (its location row went with the block); -32010 pruned: height h is below this node's retention floor f with data: {"floor": f} is answered only when a location row survived and names a height below the floor.

rand_checkTransaction

Params: [hash, key], where key is a per-transaction TxKey as 64 hex characters. Result: null for a hash this node has no committed transaction for, else what the key discloses about it:

{ "tx": "4f2c…e7", "height": 192,
  "disclosed": [
    { "output": "bundle:0", "cm": "2a9f…07", "index": 40,
      "note": { "pk": "…", "from": "…", "amount": "1500000000", "asset": 0, "time": 5, "memo": "invoice 7" } }
  ] }

Monero's check_tx_proof shape (docs/rpc-comparison.md §4): a sender who sealed an output with a fresh TxKey can hand (hash, key) to anyone — a recipient proving they were paid, an auditor checking a claim — and this call is the whole verification. Each entry of disclosed is one envelope the key opened: output names the envelope set (bundle:0 … bundle:3 for the transaction's bundle, one per output slot — slots 0–1 the private-asset outputs, slots 2–3 the RAND outputs, dummies included — deposit for a BridgeAttest's deposit envelope, token_mint for a TokenMint's note, initial_mint for a RegisterToken's initial mint, mint:0 for a faucet mint's one envelope), cm the on-chain commitment the note commits to, and index its leaf. The disclosed note carries its asset: this call — with the key the sender sealed under — is the one place the RPC reveals which asset a transfer moved. The binding is the proof: the AEAD authenticates the note and checks it against cm, so a key lifted onto another transaction — or a note that is not the commitment's preimage — yields an empty list, never a forged row. A withdraw's and a genesis alloc's envelopes are not tried: they are sealed inside the node under keys dropped at once, so no TxKey for them can exist. A mint's is sealed the same way, but its recipient recovers the key through the envelope's KEM half (rand tx-key), so a mint is tried. A token mint's and an initial mint's envelopes are sealed by the minter, and open against the commitment the chain computed for that note.

The disclosed note's memo is the sender's memo (spec 2026-09-26 §2.3) if this envelope carried one — null for no memo or a memo field that opened malformed. A memo can be present on any chain: where the genesis sets no envelope_bytes (chains 14 and 15) the ledger still accepts any note envelope up to 2 048 bytes, so a memo-carrying 1 860-byte envelope from another sender is valid and opens with its memo — wallets only seal one where the chain sets envelope_bytes. A memo is anyone's text: show it as untrusted (see docs/cli.md, "How a memo is shown"). It is readable here for exactly the same reason the note itself is: the key that opens one opens the other, from the same AEAD body.

The call is stateless: the key is used for this one request and dropped — it is not imported, stored, or learnable from anything the node keeps (unlike rand_importViewingKey, which retains). A key that opens nothing gets { "disclosed": [] }, indistinguishable from a wrong key by design. Amounts are strings, as everywhere chain state is served.

Errors: -32602 for a malformed hash or key (both are parsed before any storage read).

On a node started with --prune-history, a transaction whose block was pruned normally answers null (its location row went with the block); -32010 pruned: height h is below this node's retention floor f with data: {"floor": f} is answered only when a location row survived and names a height below the floor.

rand_getBlockByHeight / rand_getBlockByHash

Params: [height] (integer) or [hash]. Result: null if unknown, else:

{
  "hash": "647b…", "height": 50, "view": 92, "parent": "2d41…",
  "proposer": "3v3VBJ…", "timestamp_ms": 1788000123456,
  "tx_root": "0000…", "state_root": "a1b2…", "justify_view": 91,
  "tx_count": 0, "transactions": [ ...same shape as rand_getTransaction.tx... ]
}

Only committed blocks are served. justify_view is the view of the quorum certificate for the parent that this block carries.

On a node started with --prune-history, a height below rand_status.prune_floor (genesis excepted) answers error -32010 pruned: height h is below this node's retention floor f with data: {"floor": f} — ask the archive for it. A pruned block asked for by hash still answers null, as an unknown hash does: only an archive can say whether it was pruned or never existed.

rand_getHead

Params: []. Result: { "height": 1998, "hash": "…", "view": 2251 } (view is the node's current consensus view, which runs ahead of height when views time out).

rand_status (alias rand_syncStatus)

Params: []. Result:

{
  "height": 1998, "head_hash": "…", "view": 2251, "high_qc_view": 2250,
  "syncing": false, "sync_target": 1998,
  "sync_inflight_age_ms": null, "sync_failures": 0, "sync_late_batches": 0,
  "peer_count": 5, "connected_peers": 5, "ws_clients": 3, "refused_cache": 0,
  "verify_queue": 0, "mempool_size": 0,
  "is_validator": true, "active_validator": true, "faucet": true, "confidential": true,
  "fri_profile": "production", "programs": 2, "viewing_keys": 0,
  "notes": 41, "nullifiers": 12, "tree_root": "6b1d…c4", "hc_bundle": "f07a…19",
  "address": "2nRdFC…", "peer_id": "12D3KooW..."
}

syncing is true while a batch request to a peer is in flight; sync_target is the highest height any peer has advertised. viewing_keys is how many viewing keys this node is holding for node-side scanning (see rand_importViewingKey) — in memory only, so it reads 0 after every restart, and anything above it is worth an operator's attention precisely because it changes what compromising the process would disclose.

The four fields beside them are for reading a node that is behind and not catching up, which otherwise looks identical to a node that is behind and working:

  • sync_inflight_age_ms — how long the outstanding batch request has been outstanding, or null when none is. An age that keeps climbing past a few seconds is the diagnosis: the request is not coming back. The node gives up at the wire's own timeout (30 s) and tries another peer.
  • sync_failures — batch requests that failed since start: a wire or codec error, a give-up past that timeout, or a batch that arrived and could not be applied. Rising while height does not is a node that cannot catch up.
  • sync_late_batches — batches applied after their request had been given up on. Progress, not failure, but a rising count means the give-up is firing on requests that were still alive, so the peers being asked are slower than the timeout.
  • connected_peers — peers with an open connection, which are the only ones sync can ask for blocks. peer_count counts every entry in this node's peer map. Since 2026-09-24 a gossiped Status from an author this node holds no connection to no longer creates one (an entry nothing ever removed, and a peer id is free to mint), so the two numbers now differ only by peers that disconnected since the map was last pruned — peer_count far above connected_peers was hearsay before that date and is a bug after it.
  • ws_clients — WebSocket clients connected right now, against the 64 this node will carry (see Subscriptions). At 64 the next upgrade is refused with a 503, which otherwise shows up only as clients that cannot connect for no visible reason.
  • refused_cache — transactions this node has already refused for a reason that is a function of the bytes alone (a bad proof or mint signature, an oversized part) and now refuses again by hash, for free. Bounded at 8192, oldest evicted first; a count pinned at the cap is a flood of distinct bad transactions, and the per-peer gossip rate limit is the bound that actually holds.
  • verify_queue — transactions waiting for one of the four proof-verification workers that run off the consensus loop. 64 deep at most; a queue that stays full means verifications are arriving faster than ~20 ms apiece drains them, and what does not fit is shed — an honest peer re-gossips on its next heartbeat — rather than queued unboundedly.

is_validator says this node holds a validator key; active_validator says that key is in the set running the current epoch (spec §8) — a validator that has bonded in but whose epoch has not arrived is the first without the second. notes is every note the chain has ever created, nullifiers every note it has ever spent, and hc_bundle the bundle guest this chain's proofs are against — a node whose build disagrees with the genesis value refuses to start at all.

rand_getPeers

Params: []. Result: array of { "peer_id": "12D3KooW...", "addrs": ["/ip4/…/tcp/30303"], "connected_secs": 1241 }.

rand_getBridgeState

Params: []. Result on a chain without a bridge section: { "enabled": false }. Otherwise:

{
  "enabled": true,
  "emitter": "01…",                      // this chain's outbound emitter address, 32 bytes hex
  "emitters": { "2": "02…" },            // source chain id -> the emitter address trusted there
  "guardian_set_index": 0,
  "guardians": ["aabb…"],                // the current set's 20-byte addresses, hex
  "pq_guardians": ["…"],                 // the genesis PQ set: Dilithium2 public keys (1 312 bytes), hex,
                                         // index-aligned with guardian set 0; every BridgeAttest carries a
                                         // quorum of co-signatures by it, and a rotation never moves it
  "mint_paused": false,                  // B1: while true every transfer attest is refused (burns and
                                         // rotations stay open)
  "pause_nonce": 0,                      // B1: what the next M_pause / M_unpause must carry
  "list_nonce": 0,                       // B4: what the next M_list / M_register must carry
  "pause_key": "…",                      // B1: the one Dilithium2 key that may pause minting, hex
  "registration_fee": "1000000000",      // B4: what a RegisterBridgedToken owes past the bundle base, RAND units
  "burn_sequence": 1,                    // outbound messages emitted so far
  "rotation_nonce": 0,                   // v0.5.4, bridge rules v2: what the next M_rotate_pq / M_rotate_pause
                                         // must carry (always 0 on a chain without rules_v2 — chain 14)
  "rules_v2": null,                      // v0.5.4: { "global_mint_cap_per_window": "<decimal string>",
                                         // "cap_window_secs": 86400 } on a chain whose genesis has the group
  "min_inbound_sequence": null,          // C15-1: { "2": 7, "4": 3 } — per source chain the lowest
                                         // sequence a transfer may carry, from the genesis replay floor;
                                         // null on a chain without one (chain 15 and earlier)
  "assets": [ …the rows of `rand_getAssets`… ]
}

No balances: bridged value is notes, not accounts. No next_index any more (RPL, B4): a bridged token is listed — under an index the registration already fixed — before it can ever be deposited, so there is no index left to predict; a wallet reads a listed token's index off assets (rand_getAssets) or rand_getTokens.

rand_getAssets

Params: []. Result: the bridge's asset registry, ascending by index (which is registration order), or [] on a chain without a bridge:

[{ "index": 1, "chain": 2, "token": "aaaa…", "asset_id": "…", "decimals": 6, "locked": "600",
   "mint_cap_per_day": "10000000000000", "minted_today": "1000", "mint_day": 20350 }]

index is the asset word a note of that asset carries — index 0 is RAND and is never in the registry. One row per backing (source coin). locked, mint_cap_per_day and minted_today are decimal strings in the token's own eight-decimal units, never RAND's; mint_day is a plain integer, a UTC day number, not an amount. mint_cap_per_day (bridge hardening B1) is the genesis tokens.mint_cap_per_day, the most one backing may mint per UTC day of the block time; minted_today is what this backing has minted on mint_day, the UTC day (timestamp_ms / 86 400 000) of the head block — the figure the cap would count the next deposit against, so a counter left from an earlier day reads "0" once the head crosses midnight, never the stale figure. A deposit past the cap is refused MintCapExceeded and becomes admissible the next day. chain and token are the wire identity guardians sign about; asset_id is blake3 of the two, and is what rand_bridgeAssetId computes.

rand_bridgeAssetId

Params: [token_chain, token_address] where token_chain is an integer and token_address is 32 bytes of hex. Result: the asset id (64 hex characters). Pure arithmetic on its arguments, so it answers on any chain, bridged or not.

rand_getBridgeBurn

Params: [sequence] (integer). Result: null if this chain has emitted no such message, else

{ "sequence": 0, "body_hex": "…", "digest": "…", "tx": "…", "height": 2 }

body_hex is the outbound message as guardians must hash and sign it; digest is its hash. tx is the burn transaction that emitted it — a burn is funded by notes, so the transaction hash stands in for the sender identity the message has no room for.

rand_getTokens

Params: [from_index, limit], both optional (0 and 1000; limit is clamped to 1000). Result: the RPL token registry — every token, bridged and native — ascending by index from from_index, read by range from that index (never a scan of the rows below it, v0.5.4):

{ "enabled": true, "registration_fee": "1000000000", "next_index": 3, "max_tokens": null,
  "tokens": [
    { "index": 2, "id": "<64 hex>", "id_text": "rpl1…", "name": "Test Coin", "symbol": "TST",
      "decimals": 6,
      "authority": { "kind": "key", "key": "<Dilithium2 key, hex>", "address": "<base58>" },
      "mint_nonce": 1, "total_supply": "5700", "registered_at": 3 }
  ] }

On a chain without a tokens section: { "enabled": false, "tokens": [] }. index is the asset word a note of the token carries (0 is RAND and is never listed). id is the token's asset id and id_text its checksummed text form — bech32m, HRP rpl, over the 32 id bytes, 62 characters. authority is { "kind": "none" } (fixed supply, or renounced), { "kind": "key", "key", "address" }, { "kind": "bridge", "backings": [{ "chain": 2, "token": "<32 bytes hex>", "decimals": 6, "locked": "600", "mint_cap_per_day": "10000000000000", "minted_today": "1000", "mint_day": 20350 }] } (each backing's source decimals, the amount its contract holds for this chain, and bridge hardening B1's mint-cap figures — rand_getAssets's fields, one row per backing) or { "kind": "program", "program": "<hex>" }. Supplies, locked, mint_cap_per_day, minted_today and registration_fee are decimal strings (RAND units for registration_fee, token units for the rest); next_index, mint_day and max_tokens are numbers. max_tokens (v0.5.4, audit v4 TOK-1) is the genesis cap on how many tokens the registry may hold — registration is refused RegistryFull at it — or null on a chain whose genesis has none (chain 14). burn_registration_fee (v0.5.5, audit v5 TOK-2) is a boolean: whether a registration's registration_fee is burned rather than paid to the block's proposer (docs/tokens.md §15) — false on chain 14. bound_note_value (v0.5.6, deep scan) is a boolean: whether a mint or deposit is held below 2^63 as a validity rule (docs/tokens.md §16; the admission screen refuses such an amount on every chain regardless) — false on chain 14. A page shorter than limit is the last.

A wallet resolves a token id through this listing (wallet::resolve_asset), never through rand_getToken: a transfer's asset is private on chain, and reading the whole registry costs the same whichever token is meant.

rand_getToken

Params: [token] — a registry index (a number or a decimal string), the 64-hex id (with or without 0x, any case) or the rpl1… text form (all lower or all upper case). Result: that token's rand_getTokens row, or null when there is none (and on a chain without tokens). A malformed id — a bad checksum, another HRP, a wrong length, mixed case, not hex — is -32602, so a typo is never some other token.

Privacy: a per-token lookup tells the node which token the caller cares about. It is for explorers and one-off reads; a wallet about to send uses rand_getTokens.

rand_getTokenSupply

Params: [token], the same forms as rand_getToken. Result: null, or

{ "total_supply": "600",
  "backings": [{ "chain": 2, "token": "<32 bytes hex>", "decimals": 6, "locked": "600",
                 "mint_cap_per_day": "10000000000000", "minted_today": "1000", "mint_day": 20350 }] }

total_supply, locked, mint_cap_per_day and minted_today are decimal strings; mint_day is a plain integer. backings is empty for a native token; for a bridged one the locked amounts sum to total_supply, and each is what rand-bridge-audit reconciles against that coin's custody on its source chain. The same privacy note as rand_getToken.

rand_getValidators

Params: []. Result: array of

{ "address": "…", "stake": "1000000000000", "pending": [{ "release_epoch": 41, "amount": "5000000000" }],
  "rewards": "4000000", "payout": "rand1…", "nonce": 3, "active": true }

one row per entry of the register (spec §8), in address order. Since phase S2 that is every validator that has ever bonded, not the genesis set: active is the ones in the set running the current epoch, and those are what the leader rotation runs over. Amounts are decimal strings, because a JSON number is not an exact integer past 2^53 and a stake is 10^9 units per RAND. pending is the unbonding queue, oldest first; rewards is the bundle fees credited to that validator as proposer; payout is where a Withdraw pays; nonce is what its next signed Unbond or Withdraw must carry. The register is the only place this chain stores amounts in the clear — docs/staking.md is the guide to it.

rand_getEpoch

Params: []. Result: { "epoch": 41, "epoch_blocks": 1000, "next_set": ["…", "…"] }. epoch is height / epoch_blocks. next_set is what the register would derive for the next epoch if this one ended now — a projection, not a commitment: every bond and unbond before the boundary moves it. The derivation rule is in docs/staking.md §2.

rand_getSupply

Params: []. Result:

{ "height": 1998,
  "genesis_deposited": "…", "genesis_staked": "…", "faucet_minted": "…",
  "faucet_epoch": "…", "faucet_minted_in_epoch": "…",
  "withdraw_deposited": "…", "fees_paid": "…", "burned": "…",
  "subsidised": "…", "sealed_blocks": "…", "aggregator_bonds": "…", "slashed": "…",
  "registration_fees_burned": "…",
  "vesting_issued": "…", "vesting_released": "…", "vesting_in_register": "…", "vesting_locked": "…",
  "pool_value": "…", "register_total": "…", "total_supply": "…", "invariant_holds": true }

The four vesting_* fields (genesis vesting, docs/vesting.md; "0" without a vesting section): what genesis issued into the vesting register, the notes claims and revokes released into the pool (inside pool_value), what the register still holds (inside total_supply; RAND bonded from it is a validator's stake, so in register_total), and what of it has not unlocked yet at the head. vesting_issued is issuance, on the identity's right beside genesis_staked.

registration_fees_burned (v0.5.5, audit v5 TOK-2) is Σ of the registration fees burned under the genesis tokens.burn_registration_fee (docs/tokens.md §15): inside burned on the pool side and in no register entry, so the identity below subtracts it on its right beside slashed. A decimal string; "0" on chain 14, which has no gate.

faucet_epoch and faucet_minted_in_epoch (v0.5.4, audit v4 STAKE-2) are the faucet's per-epoch pair: the epoch the counter is for and what the faucet minted in it, against the genesis staking.faucet_budget_per_epoch. Both are decimal strings like the rest of this object, and both read "0" on a chain without a staking section (chain 14), where no budget applies. On a chain with one they are consensus state — in the state root and replayed by rand-node verify — not a derived count like the rest.

The supply audit. Note values are hidden, but every crossing of the pool's boundary is public, so these are exact: value enters the pool as a genesis deposit, a faucet mint or a validator's withdraw, and leaves it as a bundle fee (into a proposer's rewards) or a burn (a Bond, into stake). A withdraw's own base fee is not a crossing: withdraw_deposited counts the note it created (amount less the base), and the base moves from one register entry to another. pool_value = genesis_deposited + faucet_minted + withdraw_deposited − fees_paid − burned; register_total is Σ stake + pending + rewards over the register; total_supply is the two together, and invariant_holds is whether it still equals everything the chain issued (genesis_deposited + genesis_staked + faucet_minted) less what was destroyed (slashed and registration_fees_burned). A false there is a bug, never a legitimate chain state. The counters are not in the state root — rand-node verify --mode quick recomputes every one of them by replaying the chain, which is what makes them auditable. docs/supply.md works the identity through a bond and a withdraw and says where it rests on a claim (the genesis file's own amounts) rather than on a check.

rand_getVesting

Params: [id, at_ms?] — the entry's 64-hex id, and optionally the time to evaluate the schedule at (default: the head block's timestamp). Result, genesis vesting (docs/vesting.md):

{ "id": "3f9a…", "class": "investor", "beneficiary": "<address>", "revocable": false, "revoker": null,
  "amount": "18000000000000000", "start_ms": 1790000000000, "cliff_ms": 31104000000,
  "linear_ms": 46656000000, "step_ms": 2592000000,
  "claimed": "0", "revoked_out": "0", "revoked_at": null,
  "bonded": "0", "bonded_to": null, "unbonding": [], "nonce": 0,
  "vested_now": "0", "claimable_now": "0", "unvested_now": "18000000000000000", "as_of_ms": 1790000000000 }

Keys are served as their addresses. claimable_now is what a claim_vested may take: vested, unclaimed, and neither bonded nor still unbonding. null for an id the register does not hold; {"enabled": false} on a chain without a vesting section.

rand_getVestingSummary

Params: []. Result: { "enabled": true, "height", "as_of_ms", "entries", "issued", "released", "locked", "classes": [{ "class", "entries", "amount", "vested", "claimed", "revoked_out", "bonded", "locked" }] } — only the classes that have entries, in the order team, investor, partner, other. No key or owner appears. {"enabled": false} without the section.

rand_getVestingSchedule

Params: [from_ms, to_ms, step_ms], at most 1 000 points (-32602 otherwise). Result: { "enabled": true, "issued": "…", "points": [{ "t_ms": …, "locked": "…" }] } — the register's locked total at from_ms, from_ms + step_ms, … ≤ to_ms: the aggregate lockup table (SAFT Schedule 2 §3). Revokes already applied are counted; future ones cannot be.

rand_getVersion

Params: []. Result:

{ "version": "0.1.0", "git_sha": "c66e6b8…", "chain_id": 12, "hc_bundle": "f07a…19",
  "fri_profile": "production" }

version is the workspace crate version. git_sha is the full commit hash captured at build time by randprotocol-node's build.rs — git rev-parse HEAD in a checkout, else the .git-rev file deploy/rebuild-vps.sh writes into the tree it ships to the build host (which has no .git), else "unknown" — with -dirty appended when tracked files differ from that commit (untracked files do not count). This is how a caller outside the fleet (an explorer, a survey script) confirms which build a node is running without shelling in; the deploy's own sha-compare stays on the binary.

rand_getGenesisHash

Params: []. Result: the genesis hash as hex, e.g. "605eb783…".

Chain id alone is not enough on a project that cuts chains as often as this one: chain 11 and chain 12 could carry the same id on a misconfigured node, and this call catches that before a sync is wasted on the wrong chain.

rand_getHealth

Params: []. Result, one of:

{ "status": "disk_low", "free_bytes": "3221225472" }
{ "status": "ok" }
{ "status": "syncing", "behind": 412 }
{ "status": "behind", "behind": 30 }

disk_low, ahead of everything else, when free space on the data directory's filesystem is under four times the node's startup minimum (--min-free-disk-mb, default 1024 → under 4 GB); free_bytes is the measurement, as a decimal string, re-taken every status tick (audit v4 OPS-3). ok when sync_target - height is at most 2 and no sync batch request is outstanding. syncing while one is. behind when the node is not syncing and the lag is still above 2 — the chain-8 stall shape. A load balancer reads status alone; an operator reads behind too.

rand_getTransactionStatus

Params: [[hash, …]], 1 to 64 hashes. Result: one entry per hash, in order:

[ { "hash": "…", "status": "committed", "height": 1998, "index": 0 },
  { "hash": "…", "status": "pending" },
  { "hash": "…", "status": "rejected", "reason": "bad mint signature" },
  { "hash": "…", "status": "unknown" } ]

Lookup order per hash: committed (storage), then pending (the mempool), then rejected (the refused cache — the same cache rand_status's refused_cache counts), else unknown. A hash this node never saw and one a peer refused both read unknown; that is honest, since a wallet's submit goes to one node, not to every node that might have an opinion. A rejection is not kept forever — the cache evicts at 8192 entries — but a client polling sees it long before that.

rejected covers refusals about the transaction's own bytes only — the ones no later block can change (admission::is_permanent): an invalid bundle, call or aggregate proof; a bundle digest that is not what its proof published; a bad mint signature or a malformed program; the wrong chain id; a bundle on an action that must not carry one, or none where one is required; an envelope, proof, attestation, program, aggregate or whole transaction over its size cap; a nullifier or commitment repeated inside the transaction itself; a burn or bridge attestation inconsistent with itself; and an aggregate's own signature and cover-set verdicts (empty, too many, duplicated, an unregistered or mismatched shape or guest, a cover that is not a bundle or is already sealed). A refusal that depends on this node's state at that moment — a spent nullifier, a commitment already in the tree, an anchor or time outside the window, a fee below the floor, an unknown program, the staking and aggregator registers — is not remembered: a double-spend refused at submit reads unknown straight away (the submitter got the reason as rand_sendTransaction's error), and a pooled transaction that a block makes unspendable reads pending and then unknown once it leaves the pool.

randprotocol_client::RpcClient::wait_for_transaction calls this method and fails as soon as a poll reads rejected, rather than waiting out its timeout on a transaction that is never coming back; against a node too old for this method (-32601), it falls back to its previous polling loop.

Errors: -32602 for an empty list or more than 64 hashes.

On a pruned node (rand_status.prune_floor > 0), every unknown entry carries floor beside status: { "hash": "…", "status": "unknown", "floor": 10 } — whether or not that particular hash's height is below it, since an archive is the only place that could say which. It is still unknown, not an error — this method answers a page of hashes, not one lookup — and only an archive can say whether that hash was ever committed.

rand_getReceipts

Params: [program_id, from_height, to_height, limit?]. Result:

{ "receipts": [ { "tx": "…", "program": "…", "tier": 14, "outputs": [1, 0, 25, 0, 0, 0, 0, 0],
    "height": 17, "index": 0, "h_in": "9c0e…7f", "h_pub": null }, … ],
  "next_height": 2051 }

Receipts for program_id with from_height <= height <= to_height, ordered by height then index; the receipt object is exactly rand_getReceipt's. limit defaults to and is capped at 256, and a limit of 0 is clamped up to 1.

limit is a soft floor, not a hard page size: a page never splits a height. Once it holds limit receipts it still serves the rest of that height's matching receipts before it stops, so a receipt is never split across two pages and a caller never has to de-duplicate one across a page boundary. next_height is the first height not served at all — resume from it — or null when the range's own to_height ended the page rather than limit.

Errors: -32602 for to_height below from_height.

On a node started with --prune-history, a range that reaches a height below the floor (genesis excepted) answers error -32010 naming the first such height — pruned: height h is below this node's retention floor f with data: {"floor": f} — ask the archive for it, rather than serving the range from the floor's receipts alone (the pruned heights' receipt rows go with their blocks — see the retention pass). [0, to] still serves genesis alone when to is 0.

rand_getWitnesses

Params: [[index, …]], 1 to 32 leaf indices. Result:

{ "root": "…", "witnesses": [ { "index": 7, "path": ["…", …] }, { "index": 9, "path": null } ] }

rand_getWitness folded over many leaves in one tree build: a wallet proving several notes at once pays for the rebuild once instead of once per note. An index past the end of the tree reads path: null for that entry rather than failing the whole call. rand_getWitness is unchanged and is not implemented through this call: it keeps its own arm and its own one-leaf tree build (Storage::witness), and still answers null for an index past the tree.

Errors: -32602 for an empty list or more than 32 indices.

rand_getBlocks

Params: [from_height, to_height]. Result: a list of headers, oldest first, at most 1024 (MAX_BLOCK_HEADERS; 128 on a node before 2026-09-24) starting at from_height:

[ { "hash": "647b…", "height": 50, "view": 92, "parent": "2d41…", "proposer": "3v3VBJ…",
    "timestamp_ms": 1788000123456, "tx_root": "0000…", "state_root": "a1b2…",
    "justify_view": 91, "sealed": true, "tx_count": 0 } ]

The same header fields as rand_getBlockByHeight / rand_getBlockByHash, minus transactions — the block list a client pages through without paying for every transaction in it; those two serve the transactions. A range wider than the cap, or past the head, is truncated, not refused — a client advances from the last height it got back, which is also what makes a 1024-header ask correct against an older node's 128.

Errors: -32602 for to_height below from_height.

On a node started with --prune-history, a range that reaches a height below the floor (genesis excepted) answers error -32010 naming the first such height — pruned: height h is below this node's retention floor f with data: {"floor": f} — ask the archive for it, rather than serving the range from the floor instead. [0, to] still serves genesis alone when to is 0.

rand_getFinality

Params: [height] or [hash]. Result, one of:

{ "status": "committed", "height": 1998, "hash": "…" }
{ "status": "certified", "height": 1999, "hash": "…", "qc_view": 2251 }
{ "status": "proposed", "height": 2000, "hash": "…" }
{ "status": "unknown" }

committed is a block at or below the committed head — the only answer a height can give, since two proposals can share an uncommitted height, so a height is only ever checked against the committed chain. A hash can also read certified (in HotStuff's uncommitted tree with a quorum certificate for it — high_qc, locked_qc, the committed head's head_qc, or a child's justify) or proposed (in the tree without one yet); unknown is a hash this replica's tree has never held.

Errors: -32602 for a missing param 0, or one that is neither a height nor a block hash.

On a node started with --prune-history, a height below rand_status.prune_floor (genesis excepted) answers error -32010 pruned: height h is below this node's retention floor f with data: {"floor": f} — ask the archive for it. A hash is unaffected: it still answers committed, certified, proposed or unknown as above.

rand_getProposer

Params: [view] or [from_view, to_view], at most 64 views. Result:

{ "epoch": 3, "proposers": [ { "view": 2251, "proposer": "2nRdFC…" }, … ] }

The leader of each view under the current validator set (HotStuff::leader), not the set that actually ran at that view historically — views are not mapped to past epochs, so a view from an earlier epoch answers with the current set's leader, and epoch says which epoch's set was used.

Errors: -32602 for to below from, or a range of more than 64 views. The count is checked before any allocation, so [0, u64::MAX] is refused on the count rather than an attempt to collect the range first.

rand_getMempoolInfo

Params: []. Result:

{ "count": 2, "bytes": 2611200, "oldest_ms": 1450, "max_count": 10000 }

bytes is the sum of every pooled transaction's encoded length. oldest_ms is how long the longest-pooled transaction has waited, null when the pool is empty.

rand_getEmission

Params: []. Result:

{ "inflation": "0",
  "subsidy": { "current": "250", "base": "1000", "halving_blocks": 210000,
               "sealed_blocks": 420001, "next_halving_at": 630000 },
  "faucet": true }

inflation is a fixed "0" — nothing on this chain mints outside a genesis allocation, the testnet faucet, or the bounded aggregation subsidy, so a client expecting Solana's getInflationRate gets a number instead of a missing method. subsidy is the block-aggregation schedule (gas::subsidy, the 2026-09-15 changelog entry below); it is null on a chain whose genesis carries no aggregation section, which chain 12 does not. faucet mirrors rand_status's field of the same name.

Subscriptions (WebSocket)

The same port also speaks WebSocket: ws://127.0.0.1:8545/ or ws://127.0.0.1:8545/ws, either path. POST / is unchanged and is still where every method above is served — the socket serves only rand_subscribe and rand_unsubscribe, and answers -32601 to anything else, including reads. There is nothing to configure and no second port to open.

One thing did change for non-WebSocket clients: GET / is now the upgrade handler, so a plain GET with no upgrade headers answers 400 Bad Request where a POST-only route used to answer 405 Method Not Allowed. Nothing reads that status — the RPC has always been POST — but a health check that asserted on 405 needs to assert on 400.

Three topics exist:

  • newHeads — payload exactly rand_getHead's three fields, in the same shape — one HeadSummary serves both, so they cannot drift apart. The one difference is what view means: a notification carries the block's own view, the one its quorum certificate is for, while rand_getHead reports the node's current consensus view. For the tip of a healthy chain they are the same number. A notification is sent once per committed block, in order, including during sync: a batch of 100 synced blocks is 100 notifications, not one for the tip, so a wallet tracking heads never silently skips a height. Nothing is sent before the block is committed to storage, so a head you are told about is a head this node will not lose.
  • receipts or receipts <program_id> — one notification per committed block that carries at least one matching call receipt, in rand_getReceipt's shape; a block with none sends nothing, so a quiet chain (for that filter) is a quiet socket. A filtered subscription accepts a program id this node has never seen, since the program may be deployed after the subscribe.
  • transaction <hash> — one notification for that hash, then the node removes the subscription itself: { "status": "committed", "height", "index" } on commit, or { "status": "rejected", "reason" } when this node refuses it for good — the same reason rand_getTransactionStatus would report, from the same refused cache, so the same limit: only a refusal about the transaction's own bytes (a bad proof, digest or signature, the wrong chain, an oversize part) is announced. A state-dependent refusal — a spent nullifier, an expired anchor — is not, and neither is a transaction pruned from the pool; the subscription then waits until the socket closes, so pair it with your own timeout. A hash that had already committed or been refused when the subscribe request arrived is not answered synchronously in the subscribe reply; it is answered on the next committed block, exactly like a hash that settles afterwards, so a client has one code path whether it subscribes before or after submitting. A duplicate submission or a pool conflict is never announced here — only a permanent refusal that lands in the refused cache is.

The three topics are independent streams, not one feed kept in step: a connection subscribed to more than one may see block N's receipts notification before its newHeads notification, or the other way round. There is no ordering promise between topics, only within one.

// client -> node
{ "jsonrpc": "2.0", "id": 1, "method": "rand_subscribe",   "params": ["newHeads"] }
{ "jsonrpc": "2.0", "id": 2, "method": "rand_unsubscribe", "params": ["1"] }
{ "jsonrpc": "2.0", "id": 3, "method": "rand_subscribe", "params": ["receipts", "<program_id>"] }
{ "jsonrpc": "2.0", "id": 4, "method": "rand_subscribe", "params": ["transaction", "<hash>"] }
// node -> client
{ "jsonrpc": "2.0", "id": 1, "result": "1" }        // the subscription id, a decimal string
{ "jsonrpc": "2.0", "id": 2, "result": true }
{ "jsonrpc": "2.0", "id": 3, "result": "2" }
{ "jsonrpc": "2.0", "id": 4, "result": "3" }
{ "jsonrpc": "2.0", "method": "rand_subscription",
  "params": { "subscription": "1", "result": { "height": 1998, "hash": "…", "view": 2251 } } }
{ "jsonrpc": "2.0", "method": "rand_subscription",
  "params": { "subscription": "2", "result": { "height": 2051, "hash": "…",
    "receipts": [ { "tx": "…", "program": "…", "tier": 14, "outputs": [1,0,25,0,0,0,0,0],
      "height": 2051, "index": 0, "h_in": "9c0e…7f", "h_pub": null } ] } } }
{ "jsonrpc": "2.0", "method": "rand_subscription",
  "params": { "subscription": "3",
    "result": { "status": "committed", "height": 2051, "index": 3 } } }

rand_unsubscribe answers true when this connection held that id and false when it did not — a false is not an error, because a client tearing down after a reconnect has no way to know which ids survived. Ids are per connection, are never reused within one, and all of them go when the socket does — a delivered transaction subscription also removes its own id, the moment its one notification is sent. A frame with no id member is a notification and is refused with -32600, as over HTTP. An unknown topic, or a malformed receipts program id or transaction hash, is -32602.

This endpoint is unauthenticated, so it is bounded four ways:

  • 64 connections per node. The 65th is refused at the upgrade with HTTP 503 and a body naming the limit — not accepted and then dropped, which a client cannot tell from a network fault. rand_status's ws_clients is the live count.
  • 8 subscriptions per connection. The ninth rand_subscribe is -32000; the eight it holds are untouched.
  • 64 KiB per frame from the client. A larger frame is refused and the socket ends. (This is a bound on what the node will read; what it writes is a request reply or a newHeads, receipts or transaction notification, none of which comes near it.) Proof-carrying bodies go to POST /, which has its own much larger limit.
  • 5 seconds to take a frame, and a ping every 30 seconds. A write that does not complete in 5 s means a client that has stopped reading, and the socket is dropped rather than written to again. Without that deadline the node's task parks in the kernel's send buffer until the link is torn down — minutes — holding its connection slot, so 64 sockets from one host would close the endpoint to everyone while ws_clients still read 64 healthy clients. A connection with no subscription is never written to at all, so it is pinged every 30 s and must answer within the same 5 s. Any WebSocket library answers pings for you; a client that does not must send Pong itself.

Backpressure closes, it does not buffer. Three broadcast channels feed this endpoint, each keeping 256 entries in flight per subscriber: heads (for newHeads), committed blocks (which answers both receipts and a settled transaction), and refusals (which answers a transaction still waiting). A client that falls further behind on one than its 256 entries — because it stopped reading, or its link cannot carry what the chain produces — is closed with WebSocket code 1008 (policy violation) and a reason naming the stream and how many entries it missed. Buffering a slow subscriber is how a node runs out of memory.

A lag only closes a connection that actually holds a subscription the lagging channel feeds: a newHeads-only client is untouched by a flood of receipts or refusals elsewhere on the chain, and a receipts-only client is untouched by a lag on refusals. The recovery matches whichever stream you fell behind on — reconnect, subscribe again, and fill the gap with rand_getCompactBlocks (heads), rand_getReceipts (committed blocks), or rand_getTransactionStatus (refusals) from the last point you did see. At 3 s blocks, 256 entries is about thirteen minutes, so a subscriber that hits this was not going to catch up on the socket anyway.

Errors

code meaning
-32601 unknown method — over HTTP, and on the WebSocket for anything but the two subscription methods
-32602 invalid or missing parameter (message says which)
-32000 rejected: a transaction the mempool refused, or a subscription over this connection's cap (message gives the reason)
-32001 referenced object not found
-32603 internal error (storage or node loop)
-32600 invalid request — the body is over the size limit, is not JSON, is a malformed batch, or is a notification

Error responses look like { "jsonrpc": "2.0", "id": 1, "error": { "code": -32000, "message": "…" } }.

The transaction on the wire

rand_sendTransaction takes bincode(Transaction). There is no signature over the transaction and no sender key: a bundle authorises itself by its proof, and an action that is signed carries the signature inside itself — a faucet mint the minting validator's key and signature, an Unbond or Withdraw the register's nonce and the validator's signature over it. Those three are also the only actions with bundle: null; every other action must carry one.

Transaction { chain_id: u64, bundle: Option<Bundle>, action: Action }

Bundle {                                        // the hidden-asset bundle (chain 14)
  anchor: Word8, nullifiers: [Word8; 4], commitments: [Word8; 4],
  fee: u64, burn_a: u64, burn_r: u64, burn_asset: u32, time: u32,
  envelopes: [Envelope; 4], proof: Vec<u8>,     // postcard(rand_zkvm::Proof) of the hidden-asset guest
}
Envelope { kem_ct: Vec<u8>, to_receiver: Vec<u8>, to_sender: Vec<u8>, body: Vec<u8> }

Action::None                                            // a plain shielded transfer
Action::Mint { cm: Word8, pk: Word8, time: u32, r: Word8,   // cm = commitment of (pk, no sender,
               envelope: Envelope, amount: u64,          //   amount, asset 0, time, r), checked
               minter: PublicKey, signature: Signature } //   by admission (audit v3, POOL-1)
Action::Deploy { base_pc: u32, words: Vec<u32>, public: Vec<u32> }
Action::Call { program: Hash, proof: Vec<u8>,           // postcard(rand_zkvm::Proof)
               input_envelope: Option<CallEnvelope> }
Action::Bond { validator: Address, amount: u64, registration: Option<Registration> }
Action::Unbond { validator: Address, amount: u64, nonce: u64, signature: Signature }
Action::Withdraw { validator: Address, amount: u64, nonce: u64, time: u32, r: Word8,
                   envelope: Envelope, signature: Signature }
Action::BridgeAttest { attestation: Vec<u8>, recipient: ShieldedAddress, r: Word8, time: u32,
                       asset: u32, envelope: Envelope,
                       pq_signatures: Vec<PqSignature> }   // bridge hardening B3, last field
Action::BridgeBurn { asset: u32, amount: u64, relayer_fee: u64, to_chain: u16,
                     token: [u8; 32], to: [u8; 32] }
Action::RegisterToken { name: String, symbol: String, decimals: u8, authority: MintAuthority,
                        initial: Option<InitialMint>, salt: [u8; 32], index: u32 }
Action::TokenMint { asset: u32, amount: u64, recipient: ShieldedAddress, r: Word8, time: u32,
                    envelope: Envelope, nonce: u64, signature: Signature }
Action::SetAuthority { asset: u32, new: Option<PublicKey>, nonce: u64, signature: Signature }
Action::TokenBurn { asset: u32, amount: u64 }
Action::PauseMints { nonce: u64, signature: Signature }               // B1, bundle-less, fee-less
Action::UnpauseMints { nonce: u64, pq_signatures: Vec<PqSignature> }  // B1, bundle-less
Action::RegisterBridgedToken { name: String, symbol: String, salt: [u8; 32], chain: u16,
                               token: [u8; 32], decimals: u8, nonce: u64,
                               pq_signatures: Vec<PqSignature> }      // B4
Action::ListBacking { token_index: u32, chain: u16, token: [u8; 32], decimals: u8, nonce: u64,
                      pq_signatures: Vec<PqSignature> }               // B4

MintAuthority = None | Key(PublicKey) | Bridge { backings: Vec<Backing> } | Program(Hash)
InitialMint { amount: u64, recipient: ShieldedAddress, r: Word8, time: u32, envelope: Envelope }

Registration { public_key: PublicKey, payout: ShieldedAddress, signature: Signature }
PqSignature { index: u8, signature: Vec<u8> }   // one guardian's Dilithium2 co-signature, by set index

This block does not show the five block-aggregation actions (RegisterAggregator, UnbondAggregator, WithdrawAggregator, SlashAggregator, Aggregate) that sit between BridgeBurn and RegisterToken in the enum's declared order — see the 2026-09-15 changelog entry below for their fields. They occupy real bincode tags on any chain whose genesis carries an aggregation section; a chain without one (chain 12 onward, until it returns) never admits them, but an encoder that hard-codes tag numbers rather than deriving them from the enum still needs to count them.

A Bond must carry a bundle whose burn_r equals its amount (and whose burn_a and burn_asset are 0) — that is how the stake leaves the pool — and registration is present exactly when the validator is not in the register yet (docs/staking.md).

Every transaction carries at most one bundle. A BridgeBurn or a TokenBurn burns through it: the bundle's burn_asset must be the action's asset (never 0), its burn_a the action's amount, and its burn_r 0, while its fee pays in RAND from slots 2–3. Every other action burns no token (burn_asset and burn_a 0), and RAND is only ever burned through burn_r: a bundle with burn_asset 0 and burn_a non-zero is refused everywhere. A BridgeAttest's deposit note is the one commitment the wire does not carry — the chain computes it from the amount the guardians signed, the recipient the action names, its blinding r, its time and the registry index it names in asset, so a submitter cannot choose the amount or the owner. r is derived, not chosen (chain 14, F1): admission requires it to equal blake3("rand-deposit-r-1" ‖ mu) over the digest the guardians signed (mu), and refuses any other value with BridgeError::WrongDepositBlinding — a permanent refusal, since it depends on the transaction's own bytes alone (docs/bridge.md §5). A submitter can still choose time, within the window a bundle's time gets, which is what lets the depositor seal an envelope for a note whose commitment it can compute before knowing which block will take the transaction — and, with r derived too, two independent submitters of the same attestation at the same time name the identical note. asset is the index the envelope was sealed for: a bridged token is listed — at genesis or by a RegisterBridgedToken/ListBacking governance message — before any attestation of it is admissible, and a listing's index never moves once assigned, so there is no first-sighting registration to race for a bridge deposit (that only ever applied to the RPL registry's own RegisterToken, a different action). An attestation of a coin nobody has listed is refused BridgeError::UnlistedToken before asset is even compared; once the coin is listed, a mismatch between the index the attestation resolves to and the index the action names is TxError::AttestAssetMismatch — the attestation deposits under asset 2, and the transaction names 1 — which only a stale registry view (a node behind the listing) or a hand-built transaction can hit.

A Withdraw derives its note the same way and for the same reason: time is the head height when the command ran, and the chain, not the wire, computes the commitment (docs/staking.md).

Encoded sizes (bincode's default configuration: fixed-width integers, 8-byte length prefixes, u32 enum tags):

part bytes
Word8 32
one Envelope 1380 (1088-byte ML-KEM-768 ciphertext, two 60-byte wrapped transaction keys, a 140-byte sealed note, four length prefixes)
Bundle minus the proof 5848 (four Word8 nullifiers and commitments, four envelopes)
transfer transaction minus the proof 5861
deploy transaction minus both proofs, 100-word program 6281
call transaction minus both proofs 5902
mint transaction (no bundle) 5181 (a 1312-byte Dilithium2 key and a 2420-byte signature)
bundle proof 327,203 measured for the hidden-asset guest at tier 14 under the test FRI profile

So a shielded transfer on the wire is about 1.43 MB at the 80-query production profile, essentially all proof. The ledger caps a proof at 2 MiB by default (chains 13–15 set max_proof_bytes to 8 MiB in their genesis; note 2026-09-28), an envelope at 2048 bytes, a program at 4096 words (or the genesis file's max_program_words, at most 65 535), and a block at 4 MiB of transaction bytes — two bundles per block at that default (docs/block-space.md; a genesis may raise the caps).

A wallet builds all of this through randprotocol_client::wallet::{send, submit}, which selects the inputs, fetches the anchor and the witnesses, proves the bundle, seals all four envelopes, and checks the proof's published digest against the one it computed before it submits anything.

Changelog

What changed for clients, in one place. Newest first.

2026-09-28 — the v0.6 switch: hardening_v6 in rand_getLimits (genesis-gated; on no chain yet)

  • rand_getLimits gains a seventh field, hardening_v6: false on every chain to date. true means the genesis runs the v0.6 validity rules (docs/deploy.md), of which one changes what a wallet proves: a call against a program without a public input carries the transaction's call binding as its public segment (INT-4), and the old, unbound proof is refused as PublicValues.
  • Pool refusals a submitter may now hear on every chain (policy, never cached, never a ledger rule without the flag): ProgramUncallable for a deploy no call can hold (CPU-1), and NonCanonicalProof for a bundle or call proof whose header the honest prover would not write (INT-5, VERIFIER-1/-2).

2026-09-28 — genesis vesting (genesis-gated; on no chain yet)

  • Four bundle-less actions — claim_vested, revoke_vesting, bond_vested, unbond_vested (Action 24–27, docs/vesting.md). rand_getTransaction renders each with its entry id (hex), amount as a decimal string, nonce and, for a claim or a revoke, the note's time; a bond adds the validator and whether it registers one.
  • rand_getVesting, rand_getVestingSummary, rand_getVestingSchedule: an entry, the per-class totals, and the aggregate lockup table. {"enabled": false} without the section.
  • rand_getSupply gains vesting_issued, vesting_released, vesting_in_register, vesting_locked (all "0" without the section); invariant_holds covers the register.

2026-09-27 — the bridge replay floor (C15-1, genesis-gated; not on chain 15)

  • rand_getBridgeState gains min_inbound_sequence: the genesis bridge.min_inbound_sequence, an object of decimal chain ids to plain-number sequences, or null without one. A bridge_attest whose transfer carries a lower sequence from that chain is refused BelowReplayFloor — a permanent verdict (rand_getTransactionStatus reads rejected). Nothing changes on a chain whose genesis has no floor (docs/bridge.md §23).

2026-09-26 — address sharing and the encrypted memo: envelope_bytes, memo in disclosed notes

Spec docs/superpowers/specs/2026-09-26-address-sharing-and-memo-design.md §2.3–§2.4. Genesis-gated, node-only otherwise; a chain without the field is unaffected.

  • rand_getLimits gains a sixth field, envelope_bytes: null on every genesis without it (chains 14 and 15 and every earlier chain — wallets seal today's legacy 1 348-byte envelope and no memo), or 1860 when the genesis sets it, meaning every note-creating envelope (Bundle.envelopes, Mint, Withdraw, BridgeAttest, Aggregate, TokenMint, RegisterToken's initial mint) must be exactly that long, memo field included whether or not it carries text.
  • rand_checkTransaction and rand_getViewingNotes's disclosed note objects gain memo: null for no memo or a memo field that opened malformed — a memo can be present on any chain, a chain without envelope_bytes included (its ledger accepts a 1 860-byte envelope up to the 2 048-byte cap; wallets only seal one where the chain sets the field), so treat it as untrusted text; otherwise the sender's UTF-8 text (at most 510 bytes), readable by whoever can already open that note — the payee, the sender's own history, or anyone handed the output's per-transaction key.
  • A malformed envelope's memo field never costs the payee the note itself: only the memo is lost, not the payment.
  • The rand CLI (not an RPC change): one amount convention. rand send --asset <token>, rand token mint --amount and rand token burn <ASSET> <AMOUNT> all read the amount in the asset's display units, at its rand_getTokens row's own decimals (RAND at nine) — the same units a randpay: link's amount carries. All three took whole smallest units before; a script passing 1000000 for one unit of a 6-decimal token now moves a million of them, so scale such amounts down. send's confirmation prints both forms (10.00000000 zUSD (1000000000 units)).
  • A non-conforming envelope size (present envelope_bytes, wrong length) is refused TxError::EnvelopeSize { expected, got }, a permanent verdict.

2026-09-25 — history pruning: --prune-history, rand_status.prune_floor, error -32010

A node started with --prune-history 24h keeps the ledger and only the last day of blocks. rand_status carries prune_floor (0 on an archive) and prune_history_secs (null when the node keeps everything). Height-addressed lookups below the floor answer -32010 with the floor in data; hash-addressed lookups still answer null for a hash the node does not hold, and only an archive can say whether it was pruned or never existed. rand_getTransactionStatus adds floor to an unknown entry on a pruned node. Wallet scanning (rand_getCommitments, rand_getNullifiers, rand_getWitness) is unaffected: the notes and nullifiers families are never pruned.

2026-09-25 — v0.5.6, the deep-scan release

Node-only; no wire or genesis change on chain 14 (../security/fullnode-deep-scan-2026-09-24.md).

  • A call proof's header is pinned before any verifier key is built (DS-3). Call proofs above tier 14 (MAX_CALL_TIER), with a keccak table above 2^12 or a sha256 table above 2^13, with a program_log_height other than the deployed program's, or an input_log_height above the tier's bound are refused invalid proof with the reason in the message (CallTierTooHigh { tier, max } names the cap) — a permanent verdict, cached like any bad proof. Every call committed on chain 14 is tier 10; a tier-16 call (chain 13's ERC-20 approve) is no longer admissible anywhere (docs/confidential.md, the call validity rules).
  • A mint or deposit at or above 2^63 is refused at admission on every chain (DS-6): TokenMint, a RegisterToken initial mint and a BridgeAttest with such an amount answer Token(AmountTooLarge) / Bridge(AmountTooLarge) / AmountTooLarge before any proof is read, permanently. rand_getTokens serves bound_note_value (false on chain 14) beside max_tokens and burn_registration_fee.
  • Connection caps, and a Status from a stranger is not remembered (DS-2, DS-5). The swarm now refuses inbound connections past 256 established, 64 in handshake and 2 per remote peer (docs/deploy.md, "Topology rules"). A gossiped Status is recorded only against a peer this node holds an entry for — one it is, or was, connected to — and is metered per forwarding peer (16 back to back, refilling at 4/s, Ignore over that, exactly the transaction bucket's shape), so rand_status.peer_count no longer grows with the authors of relayed gossip.

2026-09-24 — v0.5.5

  • tokens.burn_registration_fee (genesis-gated, audit v5 TOK-2; not on chain 14). Under it a RegisterToken's or RegisterBridgedToken's registration_fee is burned instead of paid to the block's proposer, who keeps fee − registration_fee (docs/tokens.md §15). rand_getTokens gains burn_registration_fee (a boolean, false on chain 14) and rand_getSupply gains registration_fees_burned (a decimal string, "0" on chain 14), which invariant_holds now subtracts on the right of the identity beside slashed. No wire change.
  • A committed block's certificate is stored once (node-only, audit v5 OPS-4). No method, field or wire change. A committed block's QC now lives only in its child's justify (CF_QCS keeps genesis' row and the head's; a v0.5.4 database is pruned once at open — audit v5 OPS-4). Every answer that carries a certificate (rand_getBlockByHeight/ByHash's justify_view, sync's committed_block) reads the same QC as before. What a client operator should know: a node rolled back below v0.5.5 needs a resync (docs/deploy.md, "Roll note for v0.5.5").

2026-09-24 — v0.5.4

  • rand_getSupply gains faucet_epoch and faucet_minted_in_epoch (decimal strings): the faucet's per-epoch counter under the genesis staking section (audit v4 STAKE-2, docs/staking.md §2). "0" and "0" on chain 14, which has no section. A Mint over the epoch's budget is refused FaucetBudgetExhausted — a state verdict, never cached as permanent, so rand_getTransactionStatus reads unknown, not rejected, once it leaves the pool.
  • Bridge rules v2 (genesis-gated, audit v4 BRG-14 / BR-4; not on chain 14). Two new bundle-less, fee-less governance actions, rotate_pq_guardians and rotate_pause_key (rand_getTransaction kinds above; wire variants 22 and 23, appended, so every existing encoding is unchanged), under a PQ quorum of the current set over M_rotate_pq / M_rotate_pause (docs/bridge.md §21, byte for byte). rand_getBridgeState gains rotation_nonce (what the next rotation message must carry; 0 on chain 14) and rules_v2 (the group's two parameters, null without it). Under the group the per-backing mint cap is a rolling window instead of a calendar day, a global cap bounds every backing together, and a listing is refused while minting is paused. The pool treats a rotation as governance (exempt from the capacity refusal, ordered first, one pooled per rotation_nonce).
  • rand_getTokens gains max_tokens (audit v4 TOK-1): the genesis tokens.max_tokens cap on the registry, a number, or null without one (chain 14). At the cap RegisterToken and RegisterBridgedToken are refused RegistryFull. The listing now pages by range from from_index instead of scanning the registry from its first row; the reply's shape is otherwise unchanged.

2026-09-21 — wallets compute their own witnesses

Wallet-side only, no wire or node change (audit v3, PRIV-1). A wallet built from this tree no longer calls rand_getWitness at all: it keeps its own copy of the commitment tree in the note store, built during the scan from the same rand_getCommitments pages it already reads, and computes every Merkle witness itself, so a node no longer learns which leaves a spend touches. The only tree question a send still asks is rand_getAnchor, which names no leaf. rand_getWitness / rand_getWitnesses stay on the node unchanged for wallets built before this change. A note store written by an older wallet is detected on load and rescanned from leaf 0 once to build the tree.

2026-09-20 — witnesses are served from a cached tree

Node-side only, no client-visible change (audit v3, RPC-1). rand_getWitness and rand_getWitnesses used to rebuild the whole commitment tree from every leaf on every call: a wallet proving a two-input bundle paid for two full rebuilds, a hundred callers for a hundred. The node now builds the tree once per change — a commit or a truncate — and serves every witness in between from it. The concurrency cap of two rebuilds still applies, and answers are unchanged.

2026-09-20 — requests are metered per client address

Node-side only (audit v3, RPC-2 / decision D13). A client address gets 120 requests in a burst, refilling at 30 a second; a batch spends one token per request object it carries, because a batch is a request amplifier and the batch cap bounds one batch rather than the rate. Over the allowance the node answers HTTP 429 whose body is an ordinary JSON-RPC error object (-32000, "rate limited: …"), so a client that speaks only JSON-RPC can read it. Loopback is exempt, so a node's own explorer and the operator's tooling are unaffected.

This is a meter, not an authentication boundary: it bounds what one address can queue in front of the node's other work. The expensive reads keep their own bound on top — at most two witness tree rebuilds run at a time per node.

2026-09-24 — a disk guard: rand_getHealth says disk_low, rand_status carries the free bytes

Node-side only; no consensus, ledger or wire change (audit v4, OPS-3). Seven validators stalled on a full disk on 2026-09-24 with nothing in their health to say so.

  • rand_getHealth answers {"status":"disk_low","free_bytes":"<bytes>"} ahead of ok / syncing / behind while free space is under four times the startup minimum (4 GB at the default --min-free-disk-mb 1024).
  • rand_status gains disk_free_bytes (a number, not an amount) and disk_low.
  • The node refuses to start under the minimum itself, naming the directory and the flag.

2026-09-20 — the viewing-key methods are loopback-only, and a key can be removed

Node-side only; no consensus, ledger or wire change (audit v3, VK-1/2/3).

  • rand_importViewingKey, rand_getViewingNotes now answer a loopback caller only, and refuse anyone else with -32000 "… answers loopback callers only on this node". Run the node with --rpc-viewing-open to restore the old behaviour, behind something that authenticates.
  • rand_removeViewingKey(viewing_key), new: gives a key back. Frees its slot at the 64-key cap and zeroises the key in memory.
  • A scan no longer blocks the node's other work: each imported key has its own lock, so one key's scan does not hold up another's, an import, or the status the node publishes each round.

2026-09-20 — every amount is a decimal string, legacy fields included (breaking)

The one exception to the amount-is-a-string rule is gone. Every u64 amount this RPC serves is now a decimal string, chain state and a decoded transaction's own fields alike — see Conventions. Breaking for a client built against the pre-chain-14 shape: these fields changed from JSON numbers to decimal strings, no other change to their meaning or position:

  • bundle.fee (rand_getTransaction's .tx, and rand_getBlockByHeight/rand_getBlockByHash's .transactions[], same shape).
  • action.amount for kind = mint, bond, unbond, withdraw, bridge_attest (same three methods).
  • action.amount and action.relayer_fee for kind = bridge_burn (same three methods; never live on a bridged public chain before this change).
  • locked in rand_getAssets's rows and rand_getBridgeState.assets[]'s rows.

A client that reads any of these five with .as_u64()-style parsing must switch to parsing a string. Every other amount this RPC serves was already a string before this change (rand_getSupply, rand_getEmission, rand_getValidators, viewing notes, unsealed.excess, an aggregate's subsidy/proving_share, and every RPL/bridge-hardening field). rand_getStatus's aggregation.subsidy_base is now a string too, for the same reason (it was the one amount this RPC still served as a number).

2026-09-24 — rand_getBlocks pages 1024 headers; the wallet binds its store to rand_getGenesisHash

  • rand_getBlocks's cap is 1024 headers (MAX_BLOCK_HEADERS), up from the 128 it shared with rand_getCompactBlocks. A header carries no transaction, so the anchor-window reasoning behind the compact-block cap never applied to it; what did apply was the wallet's block walk, which pays a round trip per page — over a remote RPC a 240 000-block chain was ~1 900 round trips (about a quarter of an hour) before a first sync saw a leaf. Node-only, no wire or genesis change; a client asking for 1024 against an older node gets 128 and advances from the last height it got, as it always did.
  • The rand wallet now calls rand_getGenesisHash at the start of every scan and binds its note store (<key>.notes.json, new genesis field) to the answer. A store scanned against another chain — a wallet file kept across a chain cut — is emptied and rescanned from leaf 0 with a warning, instead of paging from a cursor past the new chain's tree and reporting 0 RAND, 0 notes. A node without the method (before v0.3) can no longer be scanned against.
  • The rand wallet speaks TLS: --rpc https://rpc.randprotocol.org works. It was built without a TLS backend and refused every https URL before connecting.

2026-09-20 — the token RPC and rpl1… token ids

  • New methods: rand_getTokens (the whole registry, paged), rand_getToken (one token by index, hex or rpl1…) and rand_getTokenSupply (supply and each backing's locked amount). rand_getAssets is unchanged.
  • Token ids have a text form, rpl1… (bech32m, HRP rpl, 62 characters): id_text on every token row, and accepted wherever a token is looked up. Hex stays accepted.
  • rand_getTransaction: token_mint gains r; register_token gains initial ({ amount, recipient, time, r } or null). Existing fields are unchanged.
  • rand_checkTransaction: also opens a TokenMint's envelope (output: token_mint) and a registration's initial-mint envelope (initial_mint).
  • Pool: two transactions at one token's mint_nonce (a TokenMint or a SetAuthority) or at one registration index conflict at submission, and one whose nonce or index the chain has already moved past is refused (wrong mint nonce, wrong token index) and pruned.

2026-09-19 — chain 14: the deposit blinding is derived, not chosen (F1)

A BridgeAttest's r is no longer the submitter's to pick. Admission now requires r == blake3("rand-deposit-r-1" ‖ mu), where mu is the digest the guardians signed, and refuses any other value with a new, permanent refusal, BridgeError::WrongDepositBlinding — cached like every byte-level refusal, so a relayer built against the old free-r behaviour gets refused once and then silently ignored on every retry rather than told again. time is unaffected and stays the submitter's choice, inside the usual window. The upside: two independent submitters of the same attestation at the same time now name the identical note, closing the substitution this review round found (docs/bridge.md §5).

2026-09-19 — bridged tokens listed after genesis (bridge hardening B4, chain 14)

  • Two actions, each on a RAND fee bundle its submitter pays and each authorised by a PQ guardian quorum: register_bridged_token (name, symbol, salt, the first backing's chain, token and source decimals, nonce, asset_id, pq_signers) — a new Bridge-authority token at the next index, eight decimals on Rand, under the genesis mint_cap_per_day; its fee owes the bundle base plus registration_fee — and list_backing (token_index, chain, token, decimals, nonce, pq_signers), owing the base. The quorum signs M_register / M_list (fixed big-endian layouts, docs/superpowers/specs/2026-09-19-bridge-hardening-design.md §9) at the bridge's list_nonce, which both bump.
  • rand_getBridgeState gains registration_fee and drops next_index: a bridged token is listed (under an index its registration already fixed) before it can ever be deposited, so there is no index left to predict. A wallet or an explorer reads a listed token's index off assets (rand_getAssets) or rand_getTokens instead.
  • New refusals, none cached: wrong list nonce (BadListNonce), chain N has no registered emitter (NoEmitter), and the registry's own (BackingTaken, AlreadyRegistered, TooManyBackings, BadBackingDecimals, RegistrationFeeTooLow, …) under bridge: .

2026-09-19 — the mint cap and the mint pause (bridge hardening B1, chain 14)

  • rand_getBridgeState gains mint_paused, pause_nonce, list_nonce and pause_key (hex).
  • rand_getAssets rows (and rand_getBridgeState.assets) gain mint_cap_per_day, minted_today and mint_day.
  • Two bundle-less, fee-less actions: pause_mints ({ "kind": "pause_mints", "nonce" }, the genesis pause key's signature over b"rand-bridge-pause-1" ‖ chain_id u64 BE ‖ nonce u64 BE) and unpause_mints ({ "kind": "unpause_mints", "nonce", "pq_signers" }, a PQ guardian quorum over b"rand-bridge-pq-unpause-1" ‖ chain_id ‖ nonce). Both carry the bridge's pause_nonce and bump it; the pause key can never unpause.
  • New refusals, not cached (state, not bytes): bridge minting is paused (MintsPaused) on a transfer attest while paused; mint cap … per backing per day (MintCapExceeded); and the pause's own already paused, not paused, wrong pause nonce. the pause signature does not verify is cached as a permanent refusal: it is judged after the nonce, over the transaction's own nonce and chain id under the genesis pause key, so it depends on the bytes alone — and a bundle-less, fee-less pause should not buy a free Dilithium2 verification per replay.

2026-09-19 — the hidden-asset bundle (chain 14): a hard fork

One bundle moves any asset and nobody without a key can tell which (docs/superpowers/specs/2026-09-19-hidden-asset-bundle-design.md). The bundle's wire format, the bundle guest (hc_bundle) and every transaction id change; chain 14 only.

  • Four slots. A bundle carries four nullifiers, four commitments and four envelopes, dummies included. rand_getTransaction/rand_getBlockByHeight's bundle.nullifiers, commitments and envelope_len have four elements; rand_getCompactBlocks lists four notes and four nullifiers per bundle; the tree and the nullifier set grow by four per bundle.
  • No asset, three burn fields. bundle.burn and bundle.asset are gone; bundle.burn_a, burn_r and burn_asset replace them (see rand_getTransaction). A transfer of any asset is "kind": "none": the token_transfer kind and Action::TokenTransfer no longer exist.
  • One bundle per transaction. bridge_burn has no asset_bundle; token_burn burns through the bundle's burn_a/burn_asset. New refusals: the bundle burns asset {n} on an action that burns no token, a burn of {n} is not allowed on this action, RAND burned through burn_a ({n}); a RAND burn goes through burn_r.
  • rand_checkTransaction: output is bundle:0 … bundle:3; asset_bundle:* is gone.
  • rand_sendTransaction's body cap counts four envelopes: 8 867 840 bytes on a default chain.

2026-09-19 — audit v3: the faucet mint's opening, a witness-build cap

A hard fork (the Mint wire format), for the next chain cut; nothing else in this list changes a format.

  • Action::Mint gains the note's opening, pk, time and r. Admission refuses a cm that is not the commitment of (pk, no sender, amount, native asset, time, r) with "the mint's commitment does not open to its published note and amount", and holds time to the bundle window. The minter signs under the domain rand-mint-2 over all seven fields. A faucet note's owner is therefore public, as a withdraw's payout is. rand_mint's parameters are unchanged.
  • rand_getWitness / rand_getWitnesses run at most two tree rebuilds at a time per node. A request that waits more than 10 s for a slot is refused with -32000 "witness builds are busy on this node; retry shortly".

2026-09-19 — call limits: two methods, new fields, limits from the genesis

For the chain cut that sets the call-limit genesis fields (max_proof_bytes, max_block_bytes, max_call_envelope_bytes, max_program_public_words). A default chain's answers are unchanged, apart from the new fields. Changes:

  • rand_getLimits, new: the chain's five limits at the time, so wallets stop hard-coding them (a sixth, envelope_bytes, follows in the 2026-09-26 entry below).
  • rand_getProgramPublic(program_id), new: a program's deploy-time public words, as hex of their little-endian bytes. It returns "" for a program without a public input.
  • rand_getProgram adds public_words_len and public_digest, which is null without a public input.
  • Receipts (rand_getReceipt, rand_getReceipts, the receipts topic) add h_pub. It is null when the proof was checked against the empty public input.
  • rand_getTransaction and the blocks: a deploy action adds public_words_len.
  • rand_estimateFee takes public_words for a deploy and bytes for a call. Both are optional and default to 0, so old callers get the old answers.
  • Limits from the genesis. The request-body limit and rand_sendTransaction's block-size pre-check now come from the genesis. So do the node's sync budget (max_block_bytes + 2 MiB) and its gossip transmit size (max(16 MiB, max_block_bytes + 1 MiB)). On a default chain they are the old constants.

2026-09-18 — for the v0.4 chain: the program cap is a genesis parameter

A genesis file may set max_program_words (1..=65535); absent, the cap stays 4096 words and the genesis hash is unchanged, so running chains (chain 12) see no difference. rand_estimateFee for a deploy now refuses past the chain's cap rather than the constant, with the cap in the message. No method, parameter or result shape changed. (The v0.3 entry below is the RPC release; this one lands after it and takes effect on the chain cut that sets the field.)

2026-09-18 — v0.3: eleven methods and two WebSocket topics

No wire, consensus or genesis change: old and new nodes interoperate on the wire, and the fleet takes this as a same-chain update (deploy/update-droplet.sh), not a chain cut. The database is forward-only, though, unless rand-node db drop-receipts-index is run before downgrading (see Storage below). What a client can see:

  • Node identity and health — rand_getVersion (crate version, full git sha, chain id, hc_bundle, FRI profile), rand_getGenesisHash, rand_getHealth (ok / syncing / behind).
  • rand_getTransactionStatus(hashes) — up to 64 hashes at once, each committed / pending / rejected (with the refusal reason) / unknown. randprotocol_client::wait_for_transaction now uses it and fails fast on rejected, falling back to its old polling loop against a node older than this release.
  • rand_getReceipts(program_id, from_height, to_height, limit?) — a program's receipts over a height range, paged with a soft-floor limit (default and cap 256) that never splits a height.
  • rand_getWitnesses(indices) — rand_getWitness folded over up to 32 leaves in one tree build.
  • rand_getBlocks(from_height, to_height) — up to 128 headers (1024 since 2026-09-24), rand_getBlockByHeight's fields minus transactions.
  • rand_getFinality(height_or_hash) — committed / certified / proposed / unknown from the replica's own HotStuff tree and quorum certificates.
  • rand_getProposer(view) or (from_view, to_view) — the leader per view under the current validator set, at most 64 views per call.
  • rand_getMempoolInfo — pool count, byte total, and the oldest pooled transaction's age.
  • rand_getEmission — a fixed "0" inflation, the block-aggregation subsidy schedule (null without an aggregation genesis section, which chain 12 lacks), and the faucet flag.
  • Two new WebSocket topics, receipts [program_id] and transaction <hash>, beside newHeads: see Subscriptions for their shapes, the once-then-gone behaviour of transaction, and why a lag on one topic's channel no longer closes a connection that does not listen to it.

Storage. First start of a node running this release builds a new receipts_by_program index from the existing receipts family, once — the fleet's 22 000-odd receipts take under a second; an empty node is a no-op. rand_getReceipts and the receipts topic read this index; nothing else about how receipts are stored changed. The new column family is also what makes the database forward-only: a pre-v0.3 binary refuses to open it until rand-node db drop-receipts-index --datadir <dir> (run with this release, node stopped) drops the family and its built marker — the rollback procedure is in deploy/README.md, "Rolling back v0.3".

2026-09-15 — block aggregation (chain 9): a hard fork

The wire format, block rules and consensus change: this is a hard fork, not an interop-compatible hardening. A chain whose genesis carries an aggregation section admits five new transaction actions — RegisterAggregator, UnbondAggregator, WithdrawAggregator, SlashAggregator and the Aggregate itself — prunes sealed bundle proofs, and serves a second block form on sync. Chains without the section behave byte-for-byte as before. What a client can see:

  • rand_submitAggregate is rand_sendTransaction: an Aggregate is an ordinary bundle-less transaction, admitted on the same queue (its rVM verification occupies a worker slot far longer than a bundle's ~20 ms, which the queue and the per-peer token bucket already bound).
  • rand_getBlockByHeight / rand_getBlockByHash gain sealed (every bundle in the block covered) and a per-transaction sealed_by (the covering aggregate's hash, null while the bundle is coverable or the transaction is bundle-less).
  • rand_getAggregate(hash) returns the sealing aggregate's public fields: covers (hashes, in proof order), aggregator, subsidy, proving_share, and n — the subsidy schedule's index the block minted at — plus its height. null for any other transaction.
  • rand_getAggregators lists the register (public by design): address, bond, payout, nonce, unbonding per row.
  • rand_getUnsealed(from, limit) pages the bundles an aggregator may still cover — finalised, inside the window, unsealed — as { bundles: [{ hash, height, excess }], next_from }, excess in units over the floor: the daemon's work list. Since 2026-09-28 (IFACE-7) excess is the ledger's own bucket entry — under tokens.burn_registration_fee a token registration's is fee − registration_fee − BUNDLE_BASE, not fee − BUNDLE_BASE — and a bundle with no bucket entry is not listed; rand_getAggregate's proving_share is summed the same way.
  • rand_getRawTransaction(hash) returns the full transaction, bincode as hex — the proof bytes an aggregator needs and tx_json deliberately never renders.
  • rand_getSupply gains the four counters subsidised, sealed_blocks, aggregator_bonds, slashed (reported separately from faucet_minted, so the schedule is auditable against sealed_blocks directly).
  • rand_status gains aggregation: registered, unsealed, verify_queue, and the chain parameters an aggregate daemon computes the payment from (max_covers, window, subsidy_base, halving_blocks, sealed_blocks). subsidy_base is a decimal string, like every other amount this RPC serves (2026-09-20); the rest of this object is plain integers.
  • tx_json renders the five new actions (register_aggregator, unbond_aggregator, withdraw_aggregator, slash_aggregator, aggregate) with their public fields.
  • The node CLI gains the role's commands: rand-node aggregator register|unbond|withdraw (the validator commands' twins, one register over) and rand-node aggregate [--watch], the aggregate daemon: poll rand_getUnsealed, fetch the raw bundles, prove one aggregate, submit — a separate process from the validator, needing only an RPC endpoint and the registered key. --keep-raw-proofs keeps sealed bundles' raw proofs for archives; by default the pruning pass rewrites their records once the window passes.

2026-09-14 — viewing keys in the node

A node may now hold viewing keys (never spend keys — the RPC layer has no type for those) and scan on the holder's behalf, the Zcash z_importviewingkey shape for explorers. Keys are held in memory only: at most 64 per node, cleared at restart, re-imported by the operator. No wire format, block or consensus rule changed.

  • rand_importViewingKey(viewing_key, [rescan_from_height]) registers the party viewing key's nk (64 hex); re-import of a held key is a no-op. rand_status gains viewing_keys.
  • rand_getViewingNotes(viewing_key, [from_index, limit]) lazily scans from the rescan floor (at most 10 000 leaves per call, with scanned_index/next_index/complete for progress) and pages matched notes — received rows with their nullifier and spent state, sent rows (opened through ovk) without.
  • rand_checkTransaction(hash, key) is the Monero check_tx_proof shape: stateless, one call, no key retention — what the given per-transaction TxKey discloses about the committed transaction, each opened note bound to its on-chain commitment by the AEAD. A wrong key returns an empty disclosed list, indistinguishable from a transaction that discloses nothing.

2026-09-14 — RPC hardening

No wire format, block or consensus rule changed: old and new nodes interoperate, and a fleet upgrades by ordinary restart. What a client can see:

  • rand_getCompactBlocks(from_height, to_height) is new: per block its height, hash and timestamp, and per transaction the note commitments it created (leaf index, commitment, envelope) and the nullifiers it spent. At most 128 blocks per call and, past the first block — which is always served whole — 1000 notes; resume from the last returned height plus one. This is the one-round-trip note stream a light wallet syncs with.
  • Batch requests are new: a POST body may be an array of at most 20 request objects, answered as an array of the same length in request order. A request object with no id member — a JSON-RPC notification — is refused with -32600, batched or not; an explicit "id": null is still a normal request.
  • A WebSocket endpoint on the same port (/ and /ws) is new, serving one subscription, newHeads, through rand_subscribe / rand_unsubscribe: one notification per committed block, in order, in rand_getHead's shape. Bounded — 64 connections per node, 8 subscriptions per connection, 64 KiB per client frame, 256 heads of backlog — and a subscriber that falls further behind than that is closed (code 1008), not buffered; the recovery is to reconnect and fill the gap with rand_getCompactBlocks.
  • rand_status gains three fields: ws_clients, refused_cache and verify_queue, beside the four sync fields (sync_inflight_age_ms, sync_failures, sync_late_batches, connected_peers), which are unchanged.
  • -32600 is newly documented, not new: it already answered an oversized or unparseable body, and now also covers the malformed-batch shapes and notifications.
  • The one behaviour change an existing client can notice: a transaction submitted over RPC is answered after its proof has verified on a worker rather than on the consensus loop, so the reply can take a few hundred milliseconds longer under load. The error messages are unchanged.