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) areWord8— eight little-endianu32words 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
u64amount — 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'slocked,mint_cap_per_dayandminted_today;rand_getAssets's andrand_getBridgeState.assets[]'s matching rows and both methods'registration_fee; a decoded transaction'sbundle.fee,burn_aandburn_r; andaction.amountformint,bond,unbond,withdraw,bridge_attest,bridge_burn(plus itsrelayer_fee),token_mint,token_burnandregister_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.amountformint,bond,unbond,withdraw,bridge_attest;action.amountandaction.relayer_feeforbridge_burn; andrand_getAssets's /rand_getBridgeState.assets[]'slocked— 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
idmember, batched or not: an object without one is a JSON-RPC notification, and this node refuses it with-32600rather than running it silently. An explicit"id": nullis 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.
rand-node run --rpc <ADDR> is the operator's listener: every method, batches, the
WebSocket. It identifies a caller by its socket address and trusts loopback — the viewing-key
methods answer loopback only, and loopback is not metered — so it belongs on 127.0.0.1 and
nothing that faces the Internet should forward to it. A reverse proxy or an SSH forward arrives
on loopback: behind one, every visitor is the operator.
rand-node run --public-rpc <ADDR> opens a second, public listener for exactly that hop:
- a fixed method set — the reads a wallet, an explorer or a page needs, and
rand_sendTransaction. Not served, whatever else is configured:rand_importViewingKey,rand_getViewingNotes,rand_removeViewingKey,rand_mint,rand_getPeers, and any method added later until it is listed (rpc::PUBLIC_METHODS); - no batch arrays (
-32600), and no WebSocket (GET /andGET /wshave no route); - one meter for every caller together (600 in a burst, 150 a second; HTTP 429 with a JSON-RPC body past it). Per-visitor limits belong to the proxy, which can see the visitors;
- at most half of the node's blocking-read slots, so the operator's listener is never starved.
Both listeners share the read limits: a range read is answered -32000 "busy" after 30 s, and
rand_getBlocks ends its page once it has read 64 MiB of blocks, or before the header that would
take its reply past 16 MiB (a short page, as at the head).
--rpc-viewing-token-file <PATH> puts a bearer token on the viewing-key methods of the
operator's listener: the first line of the file (32+ characters), sent as
Authorization: Bearer <token>. With it loopback alone is not enough, and a caller anywhere
that presents it is served.
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":[]}]'Params: []. Result: chain id (integer). Transactions must carry this id.
Params: []. Result: { "symbol": "RAND", "decimals": 9 }.
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.)
On a genesis with hc_auth_multisig the first term counts three proofs at the cap
(3 × max_proof_bytes: bundle, multisig auth, call), since the multisig auth proof is not the
single-key guest's small fixed shape (rpc::rpc_max_body_bytes).
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.
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.
Params: [from_index] or [from_index, limit]. Result: the same page as rand_getCommitments,
same order and same 1000-row cap, with each envelope cut to its head: scan is
kem_ct ‖ to_receiver ‖ to_sender, standard base64 with padding — 1 208 bytes for every envelope
a wallet seals, empty for a chain-made note with an empty envelope.
[ { "index": 40, "cm": "2a9f…07", "height": 37, "scan": "tBzc…" } ]The head is all a scan needs to decide ownership: decapsulating kem_ct and opening
to_receiver (or opening to_sender under ovk) succeeds only for the right key, and yields
the transaction key. A wallet pages heads, then reads with rand_getCommitments only the leaves
one of its keys opened — and any head that is not 1 208 bytes, rather than guessing at its split.
On chain 20's first 1000 leaves a head row is 1 718 bytes of JSON against a full row's 3 876.
A node too old to have the method answers -32601; rand then pages rand_getCommitments as
before.
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.
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.
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.
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.
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.
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_keysin the reply is the live count, also inrand_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_heightin 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_getViewingNotesandrand_removeViewingKeyanswer 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-openlifts 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.
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.
The registry is keyed by a one-way id of the key (blake3("rand-viewing-registry-id-1" ‖ nk)),
never by nk itself, so the only long-lived copy of the key is the import that removal zeroises
(issue #65), and the three viewing methods parse the key into buffers wiped on drop. Not wiped:
the request's own JSON text, and transient copies the trial decryption makes.
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.
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.
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.
RPL-2 (program state; genesis-gated). Params: [program_id, key], the key 64 hex characters
(a Word8, with or without 0x). Result: { "key": "<64 hex>", "value": "<64 hex>" }. A cell
the program never wrote — or wrote zeros to, which deletes it — reads as 64 zeros, the one
encoding of "absent"; so does any key of a program nobody deployed (the program's existence is
not checked). {"enabled": false} on a chain without a program_state section. A cell is
public by design (spec §2): what a wallet reads to build an invoke's reads.
Params: [program_id, { "after": "<64 hex>" | null, "limit": n }], the object optional. Result:
{ "cells": [{ "key", "value" }, …], "next": "<64 hex>" | null } — the program's cells in key
order (an unsigned comparison of the eight words), starting after after, at most limit of
them (clamped to 1 000, at least 1). next is the last key served when more follow — pass it
back as after — and null on the last page. { "cells": [], "next": null } for a program with
no cell; {"enabled": false} without the section.
Params: [program_id]. Result: [{ "asset": 0, "amount": "123" }, …], ascending by asset index
(0 is RAND, every other index the token registry's), amounts as decimal strings in the asset's
own units. A row at zero does not exist, so an empty vault — and a program nobody deployed — is
[]. {"enabled": false} without the section. Value enters a vault through an invoke's bundle
(burn_r, and burn_a of burn_asset when the transition's inflow is deposit) and leaves
it as the notes the transition's pays name.
RPL-3 (perps; genesis-gated, docs/perps.md). Params: []. Result, the exchange's public state as
of the head — the genesis section, the proved root and height, the heights a next proof must
cover, each market's median, and two counts; {"enabled": false} and nothing else on a chain
without a perps section:
{ "enabled": true,
"collateral_asset": 0, "max_tier": 18, "max_window_blocks": 64,
"max_block_inputs": 8, "min_deposit": "1000000",
"engine_hc": "<64 hex>", "genesis_root": "<64 hex>",
"markets": [{ "id": 0, "symbol": "BTC-PERP", "lot": 1000000, "tick": 1000000,
"max_leverage": 20, "maintenance_bps": 500, "taker_fee_bps": 5, "maker_fee_bps": 2 }],
"proved_root": "<64 hex>", "proved_height": 120, "head_height": 131,
"pending_heights": [121, 122, …, 131],
"medians": [{ "market": 0, "price": "64250000000" }],
"accounts": 3, "pending_withdrawals": 1 }The three commitments are 64 lowercase hex characters (a Word8, its eight words little-endian).
pending_heights are the closed heights no state proof has covered, ascending: the digests the
next proof's window is drawn from. A median is a decimal string, "0" when the last close had no
quorum of fresh oracle stake behind a price (docs/perps.md). max_block_inputs is the most perp
inputs one block records and min_deposit (a decimal string, "0" for no floor) the smallest
deposit. This is what perp-prover and the trading site poll. On the public listener.
Params: [account], the id as 64 hex (with or without 0x). Result, one trading account:
{ "account": "<64 hex>", "trading_key": "<hex of the Dilithium2 public key>",
"nonce_high": 7, "used": "9223372036854775809",
"withdrawals": [{ "request": "<64 hex>", "amount": "2000000", "height": 2 }] }nonce_high and used are the nonce window (docs/perps.md; used is the 64-bit bitmap, a
decimal string); a wallet's next nonce is nonce_high + 1. withdrawals are the account's pending
requests in request order, height the block each was admitted in. The account's collateral and
positions are the engine's state, not the ledger's, and are not served. null for an id the
exchange does not hold, and on a chain without the section. On the public listener.
Params: [{ "after": "<64 hex>" | null, "limit": n }], the object optional. Result:
{ "accounts": [<as rand_getPerpAccount>, …], "next": "<64 hex>" | null } — the accounts in id
order, starting after after, at most limit of them (clamped to 1 000, at least 1). next is
the last id served when more follow. The exchange holds at most 15 accounts, so one page is all of
them. { "accounts": [], "next": null } without the section. A non-object param is
invalid params. On the public listener.
Params: [height]. Result, a closed block's perp input words — what a prover runs the engine
over:
{ "height": 121, "digest": "<64 hex>", "words": [1, 4294967295, …] }digest is D_height, the digest the ledger recorded while the height is pending, else
recomputed over the stored words (perp_digest, domain 21); a prover refuses a window whose
digests do not match its own. words are the block's inputs in block order and the closing
Close (docs/perps.md, "The word encodings"), each a u32. null for a height this node holds
no row for: a state proof has covered it (the row is pruned with the proof), it is above the head,
or the chain has no perps section. On the public listener.
Params: [id] — the account's 64-hex id (docs/multisig.md). Result:
{ "enabled": true, "id": "9b2e…", "signers": ["<Dilithium2 public key, hex>", "…"],
"threshold": 2, "nonce": 0, "vault": [{ "asset": 0, "amount": "500000000000000000" }] }signers are in the account's order — a signature names its signer by position. nonce is the one
counter a payment and a rotation share. vault is ascending by asset (0 is RAND, every other index
the token registry's), amounts decimal strings in the asset's own units; a row at zero does not
exist. null for an id the register does not hold; {"enabled": false} on a chain without a
multisig section. Public (it is on PUBLIC_METHODS).
Params: []. Result: what a wallet needs from the chain's genesis to build a transaction — the
call limits (max_program_words through max_program_public_words), the envelope size, the v0.6
switch (hardening_v6) and the split-authorisation auth guest (hc_auth) — plus this node's own
gas policy:
{ "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, "hc_auth": null, "hc_auth_multisig": null, "gas_price": "100", "byte_price": "800",
"gas_metering": "header", "bundle_gas_limit": null, "adjust_bps": null,
"max_gas_price": null, "max_byte_price": null, "byte_load": null,
"admission_by_vote": false, "testnet": false, "slashing": null, "binding_domain": 0,
"proof_window_blocks": null, "program_state": null, "fee_rules": null, "multisig": null }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.
A wallet does not take envelope_bytes on the node's word alone (issue #64). Nothing in this
reply is authenticated, and a chain without the field still admits any note envelope up to 2 048
bytes — so a node answering 1860 there could make a wallet seal 1 860-byte envelopes among
everyone else's 1 348-byte ones, tagging every transaction it sends. The node keeps only the
genesis hash, not the file, so the claim cannot be checked against the genesis; instead the rand
wallet (and rand-node's operator commands) seal the legacy form on every chain id pinned in
randprotocol_client::LEGACY_ENVELOPE_CHAIN_IDS (14–17 today) whatever this field says. The chain
id is the transaction's own, and a transaction carrying another is refused WrongChain, so a node
cannot move it. Every chain cut without envelope_bytes must be added to that list (a test over
deploy/genesis-chain*.json fails until it is). Other clients should do the same.
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.
hc_auth is the auth guest the genesis pins (split authorisation, docs/prover.md §8), hex, or
null. Set, the chain's hc_bundle is bundle guest v3 and every bundle carries auth_commit and
an auth proof: a wallet gives the bundle guest nk and a fresh salt and proves the auth guest over
the spend key and the same binding itself. A node that predates the field answers without it — a
chain it runs has none.
hc_auth_multisig (v0.8.0) is the multisig auth guest the genesis pins (private multisig,
docs/shielded.md §2, "Private multisig: the second auth guest"), hex, or null. Set, the chain
also admits auth proofs of the multisig shape (tier 18), checked against this digest; a wallet
offers rand multisig-private only where it is set. It needs hc_auth, and the chain's
max_proof_bytes is then at least 4 MiB at Production. A node that predates the field answers
without it, which a wallet reads as null.
binding_domain (audit v6, BIND-1) is the genesis binding_domain: 0 — chains 14 to 19, and
every genesis without the field — where a transaction's binding and every signed action message
bind the chain id alone, 1 where they carry the genesis hash under fresh tags
(docs/deploy.md, "The next cut: binding_domain"). Informational, like every field here: a
wallet decides which form to prove by the transaction's own chain id
(randprotocol_client::CHAIN_ID_BINDING_CHAIN_IDS — 14 to 19 get the chain-id form whatever a
node says; every other id gets the genesis-bound form over the genesis hash the wallet's store is
bound to), and reads this only to refuse early a chain whose ledger would refuse that form. A
node that predates the field answers without it, which a wallet reads as 0.
proof_window_blocks (issue #118) is the genesis proof_window_blocks, 256 to 4 096, or null
on every genesis without the field (chains 14 to 19): how old, in blocks, a bundle's anchor may
be (one of the last N block-end roots) and how far behind the height its time may be — one
window for both, 256 for both when null. A chain's refusals name it: anchor is not one of the last N roots, time T is outside [H − N, H]. A wallet with a slow or delegated prover reads it
for how long a proof may take; the rand wallet also uses it to decide when a --no-wait spend
that never appeared can no longer commit (clamped to 256..4 096, since this reply is
unauthenticated — it moves only the wallet's own bookkeeping, never what the chain admits). A
node that predates the field answers without it, which a wallet reads as null.
fee_rules is the genesis fees section (docs/fees.md §1.3), { "burn_base": bool, "subsidy_net_of_fees": bool, "burn_floor": bool }, or null on a chain without one — and on one
whose section sets no flag true, which hashes and runs as no section at all. Informational: under
burn_base a bundle's BUNDLE_BASE is destroyed rather than paid to the proposer, and under
burn_floor (issue #135; only ever true beside burn_base) its whole settled floor — the base
plus a Deploy's per-word term, or a Call's tier-exact gas and byte terms — but what a sender pays is
unchanged — every floor is the same number — so a wallet changes nothing for those three flags.
Two more keys carry the proposer/aggregator split (docs/compute-optimization.md §6.2–§6.3), each
null when the genesis leaves it out: proposer_share_bps (a number, 0..=10 000: the proposer's
part of the base it keeps at inclusion, the rest bucketed as proving share — informational, nothing
a sender pays moves) and prove_base (a decimal string like every amount: units every bundle's
floor rises by on an aggregating chain). prove_base does move what a sender pays:
rand_estimateFee already includes it, and a wallet computing a floor itself (the gas section's
call floor, gas::fee_floor) adds it — randprotocol-client's ChainLimits::prove_base and
RpcClient::prove_base read it. Either key set makes fee_rules non-null:
"fee_rules": { "burn_base": false, "subsidy_net_of_fees": false, "burn_floor": false,
"proposer_share_bps": 4000, "prove_base": "600000" }A node that predates the field answers without it, which a wallet reads as null; one that
predates the two split keys answers without them, read as null (no split, no prove_base).
One more key carries the dollar-indexed sealing subsidy (docs/fees.md §1.3), null when the
genesis leaves it out — and set, it makes fee_rules non-null on its own:
"usd_subsidy": { "usd_micros_per_sealed_block": "4791", "max_subsidy_per_block": "300000000",
"price_max_age_blocks": 72000 }The two amounts are decimal strings (micro-dollars, RAND units), the age a number of blocks.
Informational for a wallet — nothing a sender pays moves — but an aggregator seals its payout at
the schedule it implies: the dollar target converted at the voted price (rand_getRandPrice) and
capped, while that price is fresh. The genesis price is not served here; the live one is.
gas_price, byte_price and gas_metering are this node's own gas policy (spec
2026-09-28-gas-model-design.md §4.1, Phase 0) or the chain's own gas section (§4.2, §7.1,
Phase 1) when its genesis carries one — a chain's section always wins, and a node's
--gas-price/--byte-price flags are silently ignored on such a chain (one warn! at startup).
Node policy is not a chain limit (two nodes on one chain may answer differently); a chain's own
section is consensus state and every node on the chain answers the same.
Without a gas section, gas_price/byte_price are the node's --gas-price/--byte-price in
units of 10⁻⁹ RAND (defaults 100, 800), decimal strings like every other amount on this API
(null with no policy); gas_metering is "header" while the policy prices a call's gas_max
off its proof's declared header, null with no policy (--gas-price 0 --byte-price 0). A node
that predates these fields answers without them, which a wallet reads as no policy.
With a gas section, gas_metering is "circuit" (the in-circuit meter, §4.2) and gas_price/
byte_price are the chain's current prices — under gas.dynamic (§7.1) these are the tip's
live prices, which move per block by fullness, not the genesis snapshot this node took at
startup; without dynamic they are the section's own fixed prices, which never move.
bundle_gas_limit is the bundle guest's flat declared gas (genesis gas.bundle_gas_limit;
20479 on chain 18, the tier-14 hash-free ceiling gas_max(14, 0, 0)), null without a section. adjust_bps is the dynamic controller's per-block step size in basis
points (genesis gas.dynamic.adjust_bps), null on a chain without dynamic — including one
with a gas section whose prices never move. max_gas_price/max_byte_price (decimal strings)
are the ceilings the controller never lifts a price over and byte_load is "paying" when only
a call's proof and input envelope move byte_price (audit v6, POOL-2; docs/fees.md §1.2) —
all three null where the genesis sets none, chain 18 included. admission_by_vote (audit v6, STAKE-2) is whether
the genesis sets staking.admission_by_vote: there a bond that registers a new validator key is
refused (NotAdmitted) until the validator set has voted the key in — rand_getAdmitted lists
the keys that may register (docs/staking.md §2). testnet is the genesis testnet marker
(audit v6, STAKE-2): true only where the file says so — what lets a faucet sit beside a bridge
section — so a wallet or an explorer can label the chain; false on every chain through 18.
multisig (multisig accounts, docs/multisig.md) is null on a chain without the section — where
all four multisig actions are refused — and otherwise { "create_fee": "1000000000", "max_signers": 10, "max_payouts": 4 }:
the RAND units (a decimal string) a CreateMultisig pays above the bundle base, the most signers
an account lists, and the most payouts one payment carries.
program_state (RPL-2, docs/superpowers/specs/2026-09-30-rpl2-program-state-design.md) is the
genesis program_state section and the invoke limits that come with it, or null on a chain
without the section — where every invoke is refused and no program token can be registered:
"program_state": { "cell_fee": "10000000", "max_reads": 8, "max_writes": 8, "max_payouts": 4 }cell_fee is the RAND units (a decimal string) an invoke's fee floor gains per cell it
creates — a write of a non-zero value to a cell that reads as zeros; rewriting, deleting or
writing zeros over nothing costs no cell fee. max_reads, max_writes and max_payouts (pays
and mints together) are the transition's hard caps; the binding bound in practice is the segment
rule — the transition's context words (11 + 16 per read or write + 3 per payout) must fit beside
the program's public input and the 8 binding words in a 128-row public table, so 119 context
words for a program without a public input. A node that predates the field answers without it,
which a wallet reads as null.
perps (RPL-3, docs/perps.md) is the genesis perps section's limits, or null on a chain
without it — where every perp action is refused:
"perps": { "collateral_asset": 0, "max_tier": 18, "max_window_blocks": 64, "max_payouts": 8,
"max_block_inputs": 8, "min_deposit": "1000000" }collateral_asset is the asset a deposit burns and a payout pays (0 is RAND), max_tier the
highest tier a state proof may be made at, max_window_blocks the most blocks one proof may
cover, and max_payouts the most withdrawals one proof pays, which is also the most that may be
pending at once. max_block_inputs is the most perp inputs one block records (a deposit, an
order, a cancel, a withdrawal; an account at most 8 of them a block) and min_deposit the
smallest deposit in collateral units, a decimal string ("0": no floor). A node that predates the
field answers without it, which a wallet reads as null (and a node that predates the last two,
without them: 0).
Params: [program_id]. Result: null or { "base_pc": 0, "words": [u32, ...] } (what the wallet
proves against).
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.
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.
Params: [spec], one of {"kind":"bundle"}, {"kind":"deploy","words":n,"public_words":m} or
{"kind":"call","tier":t,"bytes":b,"keccak_log_height":k,"sha256_log_height":s} (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.
On an aggregating chain whose genesis sets fees.prove_base (docs/fees.md §1.3,
rand_getLimits.fee_rules.prove_base) every kind's answer below includes it: every bundle's floor
rises by prove_base, so {"kind":"bundle"} is 1000000 + prove_base (1600000 at the
proposal's 600 000).
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.
keccak_log_height and sha256_log_height (both optional, default 0, spec 2026-09-28 §8) are the
proof header's declared hash-table heights — 0 means the table is absent. Either out of 0..=40
is -32602. On a node that announces a gas policy (rand_getLimits.gas_metering == "header") the
answer is GasPolicy::call_floor(tier, keccak_log_height, sha256_log_height, bytes): the greater of
the ledger's own tier floor and BUNDLE_BASE + gas_price·gas_max + byte_price·⌈bytes/1024⌉
(docs/fees.md §1.1). On a node with no policy the heights are accepted but change nothing — the
answer is the ledger floor alone, as before.
On a chain with its own gas section (rand_getLimits.gas_metering == "circuit", spec §3.3,
§4.2), the call spec takes a fourth field, gas — the transaction's own declared limit, not the
proof header's gas_max ceiling — and it is required: without it the call arm answers
-32602, "under the gas section a call estimate needs its gas". With it, the answer is
circuit_call_floor(gas_price, byte_price, gas, bytes) = BUNDLE_BASE + gas_price·gas + byte_price·⌈bytes/1024⌉, at rand_getLimits's current prices — the tip's, under gas.dynamic
(§7.1). keccak_log_height/sha256_log_height are accepted but unused under a section: the
declared limit already bounds the header, hash tables included. On a chain with no gas section
this field does not exist and the estimate behaves exactly as above.
{"kind":"invoke", …, "created_cells": c} (RPL-2) takes every field a call spec takes — an
invoke carries a call proof and is priced as a call — plus created_cells (optional, default 0;
at most max_writes, else -32602): the cells the transition would create, each adding the
chain's cell_fee (rand_getLimits.program_state). The caller counts them: a write of a
non-zero value to a cell rand_getProgramCell reads as zeros. On a chain without the section
the kind is -32602.
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],
"auth_commit": "0000…00", "auth_proof_bytes": 0
},
"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 atoken_burnor abridge_burn, where they equal the action'samountandasset.burn_r— RAND burned. Non-zero only on abond(equal to itsamount) and aregister_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 }—registeredis 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.timeis the note's time word, which the withdrawing node chose; the note itself is worthamountless 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_indexis what the registry gave that asset, and is theassetword of the deposit note. Both arenullfor a guardian-set rotation (which deposits nothing) and on a chain whose registry does not name the asset.assetis the index the action names, and on a committed attest it always equalsasset_index— admission refuses a transaction where they differ — but it is nevernull, so the two together say whether this node's registry can resolve the deposit at all.timeis the deposit note's owntimeword, which the action publishes and admission holds to the window a bundle'stimegets — the note is derived from it, not from the height the transaction landed at.ris 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 — andcommitmentis the leaf the chain computed from those five fields and appended —nullfor 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 intorand_getBridgeState'spq_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…" }—tokenis the backing being redeemed andtothe 32-byte destination address, hex. One bundle carries the whole burn: itsburn_assetandburn_aare the action'sassetandamount, and itsfeepays 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": nulllike a pause.nonceis the bridge'srotation_noncethe rotation spent;pq_signersare indices into the PQ set before the rotation. RefusedRulesV2Disabledon a chain withoutbridge.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'stotal_supply), and the bundle'sburn_asset/burn_arepeat them (burn_ais 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 (itsfromis the chain's fixedMINT_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 stringsbridge_attest,token_mintandregister_tokenare 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.
The RPL-2 invoke (program state, docs/superpowers/specs/2026-09-30-rpl2-program-state-design.md):
{ "kind": "invoke", "program": "<program id>", "proof_len": 268123, "input_envelope_len": null,
"transition": {
"reads": [{ "key": "<64 hex>", "value": "<64 hex>" }],
"writes": [{ "key": "<64 hex>", "value": "<64 hex>" }],
"inflow": "none" | "deposit" | "burn",
"pays": [{ "asset": 0, "amount": "300", "recipient": "<shielded address>", "time": 41,
"r": "<64 hex>", "cm": "<64 hex>" }],
"mints": [{ "asset": 2, "amount": "40", "recipient": "<shielded address>", "time": 41,
"r": "<64 hex>", "cm": "<64 hex>" }] } }A call's fields, then the state transition the proof vouched for and the ledger applied — all of
it public by design (spec §2, §9). reads are the cells the program read with the values it
read (the proof was made against them; the ledger refused the transaction unless they still
held), writes the cells it wrote; a cell key or value is a Word8, 64 hex of its eight words
little-endian, and a written value of 64 zeros deletes the cell. What came in is the bundle's
and is rendered with it: burn_r is RAND deposited into the program's vault, burn_a of
burn_asset is the token the transition's inflow names — deposit into the vault, burn
destroyed (the program's own token only), none when burn_a is 0. What went out is one
chain-computed note per payout, pays (out of the vault) then mints (new units of a token
whose mint authority is this program), in that order in the tree: every word of each note is
here under the field names a token_mint uses — time is the bundle's time, cm the leaf
the chain appended, and the from word is the chain's fixed PROGRAM_FROM — so a recipient
rebuilds it from these fields with nothing decrypted, as it rebuilds a mint. The kind string
invoke is what the wallet's scan keys on and is pinned by a test. The program's eight output
words are on the receipt (rand_getReceipt), as a call's are.
The RPL-3 perp actions (docs/perps.md) render as six kinds. Everything in them is public by
design; the engine's inputs are what its prover replays:
{ "kind": "perp_deposit", "trading_key": "<hex>", "account": "<64 hex>" }
{ "kind": "perp_order", "account": "<64 hex>",
"body": { "nonce": 5, "market": 0, "side": 0, "kind": 0, "tif": 0, "reduce_only": false,
"price": "64250000000", "size": "1000000" } }
{ "kind": "perp_cancel", "account": "<64 hex>", "nonce": 6, "target": 5 }
{ "kind": "perp_withdraw", "account": "<64 hex>", "nonce": 7, "amount": "2000000",
"recipient": "<shielded address>", "time": 41 }
{ "kind": "perp_oracle", "validator": "<base58 address>",
"prices": [{ "market": 0, "price": "64250000000" }], "nonce": 1759500000000 }
{ "kind": "perp_state_proof", "from_height": 120, "to_height": 131, "new_root": "<64 hex>",
"payouts": [{ "request": "<64 hex>", "amount": "1500000" }], "fees": "10", "proof_bytes": 268123 }A deposit's amount is the bundle's burn_r (RAND collateral) or burn_a of burn_asset (token
collateral), rendered with the bundle. An order's body is nested because its own kind
(0 limit, 1 market) would collide with the action's; side is 0 buy, 1 sell, tif 0 GTC, 1 IOC,
2 post-only. A withdrawal's envelope is the recipient's and is not rendered; the request id a
state proof's payouts name is the withdrawal's transaction hash. Prices, sizes, amounts and fees
are decimal strings.
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.
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, payout:0 … payout:3 for the notes an RPL-2 invoke paid out —
one per payout, numbered by its place in the transition, pays then mints, each opened against
the commitment the chain computed for it), 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.
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.
Params: []. Result: { "height": 1998, "hash": "…", "view": 2251 } (view is the node's current
consensus view, which runs ahead of height when views time out).
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, "compact_fetched": 0,
"peer_count": 5, "connected_peers": 5, "reserved_peers": 5, "ws_clients": 3, "refused_cache": 0,
"verify_queue": 0, "mempool_size": 0,
"is_validator": true, "active_validator": true, "faucet": true, "confidential": true,
"testnet": false, "fri_profile": "production", "programs": 2, "viewing_keys": 0,
"notes": 41, "nullifiers": 12, "tree_root": "6b1d…c4", "hc_bundle": "f07a…19", "hc_auth": null, "hc_auth_multisig": null,
"msauth_key": "absent", "binding_domain": 0,
"address": "2nRdFC…", "peer_id": "12D3KooW...",
"gas_prices": null
}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, ornullwhen 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 whileheightdoes 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.compact_fetched— transaction bodies this node fetched by hash for compact proposals and placed since start (compact-blocks spec 2026-10-08 §5.3): what a proposal named that neither its mempool nor its recent-transactions cache held. A validator that joined late or missed a burst of gossip fetches a few and stops; a count that climbs with every block says its transaction gossip is not arriving, and each of those proposals waits a round trip before the node can vote.connected_peers— peers with an open connection, which are the only ones sync can ask for blocks.peer_countcounts every entry in this node's peer map. Since 2026-09-24 a gossipedStatusfrom 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_countfar aboveconnected_peerswas hearsay before that date and is a bug after it.reserved_peers— peers admitted past the inbound connection cap (audit v6, NET-1): the bootstraps, the--reserved-peerlist and every validator identity learned from a signed peer binding, each counted once. On a fleet node it should reach the validator count within a minute of start.ws_clients— WebSocket clients connected right now, against the 64 this node will carry (see Subscriptions). At 64 the next upgrade is refused with a503, 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. hc_auth is the genesis auth guest
(split authorisation) or null, checked against the build the same way. hc_auth_multisig is the
genesis multisig auth guest (private multisig) or null, as rand_getLimits serves it; a build
whose multisig guest differs refuses to start. msauth_key says where this node's tier-18 multisig
verifier key came from: "loaded" (the pinned blob, docs/deploy.md, "The msauth verifier key"),
"built" (the development --build-msauth-key), or "absent" (no hc_auth_multisig on this
chain). binding_domain is the
genesis binding_domain (0 or 1, BIND-1), as rand_getLimits serves it.
gas_prices (spec 2026-09-28 §7.1, §8) is { "gas_price": "…", "byte_price": "…" }, the tip
ledger's current gas prices, null on a chain without a gas section. Refreshed every commit,
the same as every other field here — it is what rand_getLimits reads to serve a dynamic
chain's current prices rather than the genesis snapshot it took at startup.
Params: []. Result: array of { "peer_id": "12D3KooW...", "addrs": ["/ip4/…/tcp/30303"], "connected_secs": 1241 }.
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`… ],
"fees": null // v0.6.8: { "mint_bps": 10, "burn_bps": 10, "recipient": "rand1…" }
// — the genesis `bridge.fees`; null on a chain without it (14–19)
}fees (v0.6.8, docs/bridge.md §25): the share of every deposit (mint_bps) and every burn
(burn_bps), in basis points, that the chain mints as a zUSD note to recipient, rounded down to
a whole release unit of the backing. A wallet reads it to value its own deposit (the gross less
the fee) and, when it is recipient, to rebuild its fee notes; a burner reads it to see what will
be released.
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.
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;
Under bridge rules v2 each row also carries minted_in_window, mint_window_secs and
mint_headroom (see the 2026-09-30 changelog entry), and minted_today is the rolling-window
count; the rest of this paragraph describes a day-counter chain (chain 14).
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.
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.
Params: [sequence] (integer). Result: null if this chain has emitted no such message, else
{ "sequence": 0, "body_hex": "…", "digest": "…", "tx": "…", "height": 2,
"amount": "500000", "release_amount": "499500", "fee": "500" }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.
v0.6.8, every burn on every chain: release_amount is the amount the signed body carries —
what the source contract releases (out of which it pays the body's relayer fee) and what left
the backing's locked; it is read off body_hex itself, so it cannot disagree with what the
guardians sign. fee is the bridge fee the chain kept as a treasury note (docs/bridge.md
§25) and amount is what the bundle burned, release_amount + fee. On a chain with
bridge.fees every burn carries its split; on a chain without it (14–19) fee is "0" and
amount == release_amount. All three are decimal strings in the token's eight-decimal units.
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.
incremental_root (audit v6 TOK-1, issue #86) is a boolean: whether the registry's root is the
incremental commitment of docs/tokens.md §17 (rand-token-registry-4, the state root under
rand-state-tokens-1, one stored row per token) — false on every chain through 20; it changes
no row of this listing and no validity rule. 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.
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.
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.
Params: []. Result: array of
{ "address": "…", "stake": "1000000000000", "pending": [{ "release_epoch": 41, "amount": "5000000000" }],
"rewards": "4000000", "payout": "rand1…", "nonce": 3, "active": true, "jailed_until": null }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.
Params: []. Result:
{ "admission_by_vote": true, "max": 256, "admitted": ["rand-node address …", "…"] }Audit v6, STAKE-2 (docs/staking.md §2, "Admission by vote"): the keys the validator set has
voted in with an AdmitValidator that have not registered yet, by address, in address order —
what a Bond that registers a new key must be among on a chain whose genesis sets
staking.admission_by_vote. max is how many the ledger holds at once (MAX_ADMITTED). On a
chain without the flag admission_by_vote is false, the list is always empty and a
registration needs no vote. Served from storage, as of the committed head.
Params: []. Result:
{ "price_micros_per_rand": "150000", "set_at_height": 4210, "nonce": 3, "fresh": true,
"max_age_blocks": 72000 }The validator set's voted RAND/USD price (docs/fees.md §1.3, docs/staking.md "The RAND price
vote"): micro-dollars per RAND as a decimal string, the height of the block that set it (0 for the
genesis initial_price_micros), and the update's nonce — the next SetRandPrice must carry
nonce + 1. fresh is judged for the next block, (head + 1) − set_at_height ≤ max_age_blocks: the earliest an aggregate submitted now can land, and so the schedule it is paid
at — rand-node aggregate seals at the dollar target while it is true and at the RAND schedule
while it is false. null on a chain whose genesis has no fees.usd_subsidy (a chain with it
always has a price: initial_price_micros is required). Served from storage, as of the committed head (the head is read before the
price, so a commit between the two reads can only make the price newer than the height). Public.
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.
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": "…", "base_fees_burned": "…",
"vesting_issued": "…", "vesting_released": "…", "vesting_in_register": "…", "vesting_locked": "…",
"program_rand_out": "…", "program_rand_held": "…",
"multisig_issued": "…", "multisig_rand_in": "…", "multisig_rand_out": "…",
"multisig_base_out": "…", "multisig_rand_held": "…",
"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.
base_fees_burned (fee feedback, unreleased) is Σ of the bundle bases (BUNDLE_BASE each) burned
under the genesis fees.burn_base (docs/fees.md §1.3) — under fees.burn_floor beside it, each
bundle's whole burned floor (min(fee, floor)), one counter for the whole burn under either flag:
inside burned on the pool side, in no register entry, subtracted on the identity's right beside
registration_fees_burned. Under the flag fees_paid counts only what proposers kept — the tip. A
decimal string; "0" on every chain without the flag.
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,
registration_fees_burned and base_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.
program_rand_out and program_rand_held (RPL-2, program state; "0" without a
program_state section): RAND that invokes have paid out of program vaults as notes (value
entering the pool, inside pool_value beside withdraw_deposited), and what the vaults still
hold — register-side value, inside total_supply. It entered through an invoke's bundle
burn_r, so it is inside burned on the pool side; this is its register-side twin, and a
token in a vault is still in that token's total_supply.
perps_rand_out and perps_rand_held (RPL-3, perps; "0" without a perps section and on a
chain whose collateral is a token): RAND that state proofs have paid out of the exchange as notes
(value entering the pool, inside pool_value), and what it still holds, deposited - paid. A RAND
deposit entered through a bundle's burn_r, so it is inside burned on the pool side, and this is
its twin, as the program vaults' pair is; invariant_holds covers it. A token collateral's supply
does not move on the way in or out, so nothing is counted for it.
multisig_issued, multisig_rand_in, multisig_rand_out, multisig_base_out and
multisig_rand_held (multisig accounts, docs/multisig.md; "0" without a multisig section):
what genesis seeded the accounts with (issuance, beside genesis_staked and vesting_issued);
every burn_r a create or deposit put in (already inside burned: pool to register, not new
value); the RAND payout notes (value entering the pool, inside pool_value beside
withdraw_deposited); the bundle bases a payment paid into proposers' rewards (register to
register, not a crossing); and issued + rand_in − rand_out − base_out, what the accounts still
hold — inside total_supply. Tokens in a vault stay in the token's own total_supply.
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,
"revokers": [], "threshold": 0, "treasury": 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, "revoke_nonce": 0,
"vested_now": "0", "claimable_now": "0", "unvested_now": "18000000000000000",
"locked_now": "18000000000000000", "as_of_ms": 1790000000000 }Keys are served as their addresses. A revocable entry lists its revokers in the order a revoke's
signer indices count them, the threshold of them a revoke needs, and the treasury (rand1…) a
revoke pays — the only address it may pay. claimable_now is what a claim_vested may take: vested,
unclaimed, and neither bonded nor still unbonding. nonce is what the holder's actions sign over
and revoke_nonce what a revoke does. unvested_now is what a revoke_vesting's note may carry:
before a revoke the part not yet vested, after one what is still in the register for the treasury;
locked_now is the part still locked for the holder ("0" once revoked). null for an id the register does not hold;
{"enabled": false} on a chain without a vesting section.
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.
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.
Params: []. Result:
{ "version": "0.1.0", "git_sha": "c66e6b8…", "chain_id": 12, "hc_bundle": "f07a…19",
"hc_auth": null, "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. fri_profile is
the genesis profile's name, and it binds two parameter sets, both constants of the build: the
inner RV32 proofs' (80 queries, rate ⅛, 20 grinding bits at production) and the rVM aggregate's
(92, rate ¼, 24 since circuits 71e1a04) — docs/aggregation.md, "Before enabling aggregation".
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.
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.
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.
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.
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.
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.
A page is also bounded in bytes, and ends early — never before its first header, so a walk always
advances: after the block that takes what it has read past 64 MiB (MAX_RANGE_READ_BYTES), and
before the header that would take its serialised reply past 16 MiB (MAX_HEADER_REPLY_BYTES, a
quarter of the command-line wallet's 64 MiB reply cap; audit v7, RPC-5).
Each header carries public_notes: the block's transactions that append a note whose every word
is public — bridge_attest, token_mint, register_token, invoke, and bridge_burn on a chain
with bridge.fees — as { "hash", "raw", "proofs_stripped": true }. raw is the transaction's
wire encoding in hex with what a note's rebuild never reads emptied: the bundle's proof and
auth_proof, an invoke's call proof, and a deposit's pq_signatures. The attestation (its body
and ECDSA signatures), every action field, the bundle's public fields and the envelopes are
kept. The copy hashes differently from the committed transaction; hash is the real id, which a
burn's fee note is blinded over. For the committed bytes, ask rand_getRawTransaction by that
hash. A client that meets a reply too large to read from an older node asks for a shorter range.
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.
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.
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.
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. Beside max_count the pool
is capped at eight times the genesis max_block_bytes in bytes (audit v6, CH-7): past it a
transaction paying more above its floor per KiB displaces the cheapest, and one paying less is
refused mempool full; governance actions pass both caps.
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. subsidy.current is the
schedule's subsidy(sealed_blocks): what the next aggregate mints, except under the genesis
fees.subsidy_net_of_fees (docs/fees.md §1.3), where it is a ceiling on the mint — the
aggregate mints only its shortfall over the covered proving shares, which rand_getAggregate's
subsidy reports per aggregate. Under the genesis fees.usd_subsidy current is the schedule an
aggregate in the next block is paid at — the dollar target converted at a price fresh for that
block and capped, or subsidy(sealed_blocks) clamped to the same cap when the price is stale
(aggregation::schedule_subsidy, the one function the ledger and the daemon use); base,
halving_blocks and next_halving_at still describe the RAND schedule it falls back to. faucet
mirrors rand_status's field of the same name.
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 exactlyrand_getHead's three fields, in the same shape — oneHeadSummaryserves both, so they cannot drift apart. The one difference is whatviewmeans: a notification carries the block's own view, the one its quorum certificate is for, whilerand_getHeadreports 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.receiptsorreceipts <program_id>— one notification per committed block that carries at least one matching call receipt, inrand_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 reasonrand_getTransactionStatuswould 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.
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
503and a body naming the limit — not accepted and then dropped, which a client cannot tell from a network fault.rand_status'sws_clientsis the live count. - 8 subscriptions per connection. The ninth
rand_subscribeis-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,receiptsortransactionnotification, none of which comes near it.) Proof-carrying bodies go toPOST /, 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_clientsstill 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 sendPongitself.
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.
| 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": "…" } }.
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
auth_commit: Word8, auth_proof: Vec<u8>, // v0.6.3, chain 17+: split authorisation's c and
} // auth proof; zeros and empty without hc_auth
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
Action::Invoke { program: Hash, proof: Vec<u8>, input_envelope: Option<CallEnvelope>,
transition: Transition } // RPL-2, tag 33 (after the vesting actions and audit v6's AdmitValidator … CancelRotation, 28–32)
Action::PerpDeposit { trading_key: PublicKey } // RPL-3, tag 34: rides a bundle
Action::PerpOrder { account: Word8, body: PerpOrderBody, signature: Signature } // 35, bundle-less, fee-less
Action::PerpCancel { account: Word8, nonce: u64, target: u64, signature: Signature } // 36
Action::PerpWithdraw { account: Word8, nonce: u64, amount: u64, recipient: ShieldedAddress,
r: Word8, time: u32, envelope: Envelope, signature: Signature } // 37
Action::PerpOracle { validator: PublicKey, prices: Vec<PerpPrice>, nonce: u64,
signature: Signature } // 38
Action::PerpStateProof { from_height: u64, to_height: u64, new_root: Word8,
payouts: Vec<PerpPayout>, fees: u64, proof: Vec<u8> } // 39, unsigned
PerpOrderBody { nonce: u64, market: u32, side: u8, kind: u8, tif: u8, reduce_only: bool, price: u64, size: u64 }
PerpPrice { market: u32, price: u64 }
PerpPayout { request: Word8, amount: u64 }
Action::CreateMultisig { salt: [u8; 32], signers: Vec<PublicKey>, threshold: u8 } // tag 40, bundle-carried
Action::MultisigDeposit { account: [u8; 32] } // tag 41, bundle-carried
Action::MultisigPay { account: [u8; 32], nonce: u64, time: u32, pays: Vec<Payout>,
signatures: Vec<SignerSignature> } // tag 42, bundle-less
Action::MultisigRotate { account: [u8; 32], nonce: u64, signers: Vec<PublicKey>, threshold: u8,
signatures: Vec<SignerSignature> } // tag 43, bundle-less, fee-less
SignerSignature { index: u8, signature: Signature } // by position in the account's signers
MintAuthority = None | Key(PublicKey) | Bridge { backings: Vec<Backing> } | Program(Hash)
InitialMint { amount: u64, recipient: ShieldedAddress, r: Word8, time: u32, envelope: Envelope }
Transition { reads: Vec<Cell>, writes: Vec<Cell>, inflow: Inflow, pays: Vec<Payout>, mints: Vec<Payout> }
Cell { key: Word8, value: Word8 } // keys strictly ascending in each list
Inflow = None | Deposit | Burn // what the bundle's burn_a of burn_asset is to the program
Payout { asset: u32, amount: u64, recipient: ShieldedAddress, r: Word8, 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.
What changed for clients, in one place. Newest first.
rand_status,rand_getLimits:hc_auth_multisig(hex,nullwithout it).rand_status:msauth_key("loaded","built"or"absent"), where the node's tier-18 multisig verifier key came from.rand_sendTransaction: on a genesis withhc_auth_multisigthe request-body cap counts three proofs atmax_proof_bytes. No wire, txid or binding change: a multisig-authorised bundle has the same fields as any v3 bundle, and only its auth proof's declared shape differs (docs/shielded.md§2). A multisig-shaped auth proof on a chain without the field is refusedInvalidAuthProof, permanently; one sent while the node's key is still cold is deferred with a retryable refusal.
New public read rand_getScanLeaves(from_index, limit): rand_getCommitments' rows with
envelope replaced by scan, the base64 of kem_ct ‖ to_receiver ‖ to_sender (above). Nothing
on chain changes. rand scans with it and falls back to rand_getCommitments against a node that
answers -32601. The randprotocol.org relay forwards only the methods on its own list, so a
browser wallet behind it keeps the full pages until the relay lists this one.
Wire change for aggregates (on every chain, though none has carried one): Action::Aggregate
gains a last field, layout (Flat or Tree, a trailing 4-byte enum index), so its encoding and
txid move; a Tree aggregate signs under rand-aggregate-tree-1 (genesis-bound
rand-aggregate-tree-2), a Flat one exactly as before. tx_json's aggregate gains layout
("flat" or "tree"). A Tree is admitted only on a chain whose genesis has an
aggregation_tree section (refused TreeNotAdmitted otherwise), over exactly L·2^d covers.
rand_status.aggregation gains tree, present only on a chain whose genesis has the section:
shape, leaf_size, max_depth, leaf heights, vk_leaf, and steps, each { heights, program_digest, key }. Every digest is a 64-character lowercase hex string (the four
canonical u64 limbs, 16 hex digits each, circuits docs/08's spelling), never JSON numbers — a limb
past 2^53 would lose bits in a JavaScript reader. It is what rand-node aggregate --layout tree
rebuilds its tree from (rpc::TreeStatus::to_config reads it back exactly). A Tree is bounded by L·2^max_depth, not by
aggregation.max_covers (the flat cap). The refusal cache (rand_getTransactionStatus's
rejected) now holds TreeNotAdmitted, TreeLayout, TreeDepth, TreeTier,
CoveredPublicValues and TreeRootDigest as permanent; TreeKeyPin and TreeRebuild (this
build cannot rebuild the section) are not cached.
Unreleased — dollar-indexed prover pay: rand_getRandPrice, fee_rules.usd_subsidy, SetRandPrice (tag 44) (genesis-gated; no chain carries it yet)
Additive. A genesis fees.usd_subsidy section (docs/fees.md §1.3; aggregating chains only)
pays a sealed aggregate a dollar target converted at a RAND/USD price the validator set votes —
not an oracle. New: rand_getRandPrice ({price_micros_per_rand, set_at_height, nonce, fresh, max_age_blocks} or null; public); rand_getLimits.fee_rules.usd_subsidy (null, or the three
numbers, the two amounts as decimal strings) — every existing fee_rules object gains the key as
null; rand_getEmission.subsidy.current is the dollar schedule while the price is fresh for the
next block. tx_json renders set_rand_price (price_micros_per_rand decimal string, nonce,
voters by address in listed order). Wire change for aggregates (on every chain, though none has
carried one): Action::Aggregate gains a last field, payout_total (the amount its payout note
was sealed for, signed under rand-aggregate-4), so its encoding and txid move; tx_json's
aggregate gains payout_total (a decimal string); an aggregate whose payout_total is not what
the ledger pays now is refused PayoutMismatch (a state verdict, not cached) and evicted from the
pool at the next tip. The action is tag 44, after multisig's 40–43, bundle-less
and fee-less; refused UnsupportedAction on a chain without the section. rand_getAggregate's
subsidy and rand_getSupply's subsidised move by what the ledger minted, as before.
Unreleased — multisig accounts: rand_getMultisig, four actions (tags 40–43) (genesis-gated; no chain carries it yet)
Additive. A genesis multisig section (docs/multisig.md) switches on M-of-N accounts: public
per-asset balances spent by threshold of n Dilithium2 keys. New: rand_getMultisig [id]
({enabled, id, signers, threshold, nonce, vault}, null, or {"enabled": false});
rand_getSupply gains multisig_issued, multisig_rand_in, multisig_rand_out,
multisig_base_out, multisig_rand_held (decimal strings, "0" without the section);
rand_getLimits gains multisig (null, or { create_fee, max_signers, max_payouts }). tx_json
renders four kinds: create_multisig (salt, threshold, signer_count), multisig_deposit
(account), multisig_pay (account, nonce, time, pays with each note's cm as an invoke's,
signers = the indices that signed) and multisig_rotate (account, nonce, threshold,
signer_count, signed_by = the indices that signed). A block header carries a MultisigPay's
notes in public_notes. The four actions are tags 40–43, after the perps actions (34–39) — an encoder must derive tags
from the enum. All four are refused (UnsupportedAction("multisig")) on a chain without the section.
Unreleased — the proposer/aggregator split: fee_rules.proposer_share_bps and prove_base (genesis-gated; no chain carries it yet)
Additive. rand_getLimits.fee_rules gains proposer_share_bps (a number) and prove_base (a
decimal string), each null when the genesis fees section leaves it out; either set makes
fee_rules non-null, so on such a chain the three older flags appear too (as false where
unset). Every existing fee_rules object gains the two keys as null. Under prove_base
(aggregating chains only, docs/fees.md §1.3) every bundle's floor rises by it and
rand_estimateFee includes it in every kind's answer; a fee at the old floor is refused
FeeTooLow naming the new minimum. Under proposer_share_bps a proposer's rewards grow at
inclusion by its share of the base only, and rand_getUnsealed's excess (and so
rand_getAggregate's proving_share) carries the rest of the base beside the tip and
prove_base. rand_getSupply is unchanged: nothing new crosses the pool boundary.
Unreleased — fee feedback: the genesis fees section and the burned base (genesis-gated; no chain carries it yet)
Additive. rand_getSupply gains base_fees_burned (a decimal string, "0" on every chain without
the flag): the bundle bases destroyed under the genesis fees.burn_base (docs/fees.md §1.3), on
the right of the supply identity beside registration_fees_burned. rand_getLimits gains
fee_rules ({ "burn_base", "subsidy_net_of_fees", "burn_floor" }, booleans), null on a chain
whose genesis has no fees section with a true flag. Under burn_base a proposer's rewards
grow by the tip (fee − BUNDLE_BASE), or by nothing at inclusion on an aggregating chain — under
burn_floor (issue #135) by fee − floor, the whole settled floor burned and counted in
base_fees_burned; no fee a wallet pays changes. Under subsidy_net_of_fees rand_getAggregate's
subsidy (and rand_getSupply's subsidised) carry only the minted part, the schedule's shortfall
over proving_share. Every existing field keeps its value on every chain without the section. One
fix applies on every chain: rand_getAggregate's subsidy, proving_share and n are now stored
from the ledger's own payment, so a node that synced the covered bundle and its aggregate in one
commit no longer reports a proving_share of 0 (and, under the flag, the full schedule as
subsidy) where a live node reports the paid amounts. Records written before the fix keep their
stored values.
docs/perps.md, docs/superpowers/plans/2026-10-03-rpl3-perps.md. Inert on every chain whose
genesis has no perps section: every perp action is refused, rand_getPerps answers
{"enabled": false} and the state root is what it was.
- Six new actions (
Action34–39, appended afterInvoke):perp_deposit(a bundle's burn funds a trading account),perp_order,perp_cancel,perp_withdrawandperp_oracle(signed, bundle-less, fee-less), andperp_state_proof(a STARK of the engine over a window of blocks; unsigned, so anyone may prove).rand_getTransactionrenders each; the kind strings are above. rand_getPerps,rand_getPerpAccount,rand_getPerpAccounts,rand_getPerpInputs: the exchange's state, an account, a page of accounts and a closed block's input words. All four are on the public listener.rand_getLimitsgainsperps({ collateral_asset, max_tier, max_window_blocks, max_payouts, max_block_inputs, min_deposit }ornull);rand_getPerpscarriesmax_block_inputsandmin_deposittoo.rand_getSupplygainsperps_rand_outandperps_rand_held(both"0"without the section);invariant_holdscovers them.- A payout is a note the chain computes, appended with the withdrawal request's envelope, so a
wallet finds it by trial decryption as it finds a mint;
fromis the fixedPERP_FROM. - The state root is
rand-state-9on a chain with the section, and block time is bounded there as on a bridged chain.
Additive. rand_getTokens gains incremental_root (boolean, false on every chain through 20):
whether the chain's genesis carries tokens.incremental_root: true (docs/tokens.md §17), under
which the registry's root is an incremental merkle commitment over rand-token-leaf-2 leaves
(rand-token-registry-4; the state root re-domained rand-state-tokens-1) and the node stores one
row per token, rewriting only what a block changed. No validity rule, no listing row and no other
method changes; a client that recomputes the registry root from rand_getTokens must use the new
leaf domain and registry domain on such a chain. Node-only on every chain: the root is computed
once per change and reused, so a block that moves no token re-hashes none — the value is the same.
2026-10-01 — audit v7, RPC-5: rand_getBlocks pages bounded by their reply; every public note stripped (node-only)
A header page ends before the header that would take its serialised reply past 16 MiB
(MAX_HEADER_REPLY_BYTES), as well as after 64 MiB of blocks read; never before its first header.
And every entry in public_notes is now carried stripped and marked "proofs_stripped": true —
bridge_attest, token_mint and register_token included, which came whole before: the bundle's
proof and auth_proof emptied, and a deposit's pq_signatures; every field a note is rebuilt
from, the attestation and the envelopes stay. hash is the real id, as for burns and invokes. A
deposit carried whole was ~2.9 MB of proofs doubled by hex, so about a dozen in one page made a
reply past the command-line wallet's 64 MiB cap, which it refused — and asked for again, for ever:
a fresh wallet's first sync stopped for good. The wallet also halves its page when a node (one
without this change) answers one too large, down to a single height. Additive for a reader of
the JSON; a client that needs the committed bytes of a carried transaction reads
rand_getRawTransaction by its hash.
Additive. A rand_getBlocks header's public_notes now also carries every invoke in its block,
with every proof stripped (the bundle's, the auth proof and the call proof; "proofs_stripped": true) under its real hash. An invoke's payout and mint notes are public (recipient, amount,
asset, blinding, the bundle's time), and a wallet that reads header pages fetches no block — so on
v0.6.8 nodes it found a payout only through the envelope the invoker sealed. A wallet rebuilds the
notes from the stripped copy (the client's rebuilt_notes_with already does); nothing else changes.
docs/bridge.md §25. Inert on every chain whose genesis bridge section has no fees group (chains
14 to 19): every field below then reads as the old rule.
rand_getBridgeStategainsfees:{ "mint_bps", "burn_bps", "recipient" }(bps numbers, the recipient arand1…address) ornull.rand_getBridgeBurngainsrelease_amount,feeandamount(decimal strings) on every chain.release_amountis the body's own amount — what guardians sign and the source contract releases. Underbridge.feesit is the burn less the chain's fee; without itfeeis"0"andrelease_amount == amount. Relayers and guardians sign the body as before: under the group the body already carriesrelease_amount.rand_getTransaction'sbridge_attestgainsdeposit_amount(the depositor's note value:amount, the gross the guardians signed, less the fee) andfee_note({ amount, asset, time, r, commitment }— the treasury's note, every word but its owner — ornull);commitmentis now the net deposit's leaf.bridge_burngainsrelease_amountandfee_note.rand_getBlocksheaders carrybridge_burntransactions inpublic_noteson a chain with the group, with their proofs emptied and"proofs_stripped": true;hashis the real transaction id (the stripped copy hashes differently, and the fee note's blinding is over the real one).rand_getCompactBlocks/rand_getCommitments: an attest owns its fee note after its deposit, a burn after its bundle's four; a fee note's envelope is empty (sealed to nobody — the treasury rebuilds it).rand_checkTransactionopens the net deposit.
docs/superpowers/specs/2026-09-30-rpl2-program-state-design.md. Inert on every chain whose
genesis has no program_state section; chain 20 is the first that can carry one.
- A new action,
invoke(Action33, appended after audit v6's 28–32): a call whose proof vouches for one declared state transition of a program, which the ledger applies.rand_getTransactionrenders it as a call plus its transition — cells read and written, the inflow kind, and every payout note with all its words (timeis the bundle's;cmthe leaf appended). Its receipt is a call's:rand_getReceipt,rand_getReceiptsand thereceiptstopic carry it unchanged. rand_getProgramCell,rand_getProgramCells,rand_getProgramVault: a program's cells (one, or paged in key order) and its vault.{"enabled": false}without the section. All three are on the public listener.rand_getLimitsgainsprogram_state({ cell_fee, max_reads, max_writes, max_payouts }ornull);rand_estimateFeetakes{"kind":"invoke", …, "created_cells": c}— a call's estimate pluscell_fee · c.rand_getSupplygainsprogram_rand_outandprogram_rand_held(both"0"without the section);invariant_holdscovers the vaults.rand_getCompactBlocksandrand_getCommitmentsserve an invoke's payout notes at the leaves the ledger gave them (after the bundle's four, pays then mints), each with the payout's own envelope, so a wallet finds a note paid to it by trial decryption as it finds a mint.rand_checkTransactionnames thempayout:<i>.register_tokenmay carry"authority": "program"under the section (a token the named program mints and burns through its invokes);rand_getTokenrenders such an authority as{ "kind": "program", "program": "<id>" }, as it always could.
rand_getLimitsgainsproof_window_blocks(a number, 256..4 096, ornull): the genesis window that replaces both the 256-block anchor window and the 256-blocktimewindow.nullon chains 14 to 19, where both stay 256; chain 20 is the first that can carry it (docs/deploy.mdrecommends 1024).- The two refusals name the window in force:
anchor is not one of the last N rootsandtime T is outside [H − N, H](-32000), whereNwas always 256 before. - Nothing on the wire changes; no existing chain's rules move.
2026-10-01 — audit v6, BRG-14: rotation possession, delay and cancel (genesis-gated; no chain carries it yet)
rand_getBridgeStategainsrotation_rules({ "delay_secs", "needs_possession" },nullwithout the genesisbridge.rotationgroup) andpending_rotations(nullwithout it; else a list of{ "kind": "pq_guardians", "new_pq_guardians": [hex…], "effective_at_secs" }/{ "kind": "pause_key", "new_pause_key": hex, "effective_at_secs" }).- Three new transaction kinds in
tx_json:rotate_pq_guardians_v2(the v1 fields pluspossession_signatures, a count),rotate_pause_key_v2, andcancel_rotation(rotation_kind"pq_guardians"/"pause_key",nonce). Wire variants 30, 31, 32 (after STAKE-2'sAdmitValidator= 28 and STAKE-1'sSlashEquivocation= 29).
rand_getLimitsandrand_statusgainbinding_domain(0or1): whether the chain's transaction bindings (rand-tx-bind-2,rand-call-bind-2) and its signed action messages — a faucet mint, unbond, withdraw, the RPL token mint and authority messages, the aggregator actions, the bridge governance messages — carry the genesis hash. Chains 14 to 19 answer0; chain 20 is the first that can carry1.- A wallet never takes the node's word for it. The form it proves is decided by the
transaction's chain id: 14 to 19 are pinned to the chain-id form (
CHAIN_ID_BINDING_CHAIN_IDS), every other id gets the genesis-bound form over the store's genesis hash, and a node claiming0there only gets a refusal before any proof is made. A client of another kind (randscan, the apps) must follow the same rule before any chain is cut with the field. - Nothing on the wire changes; no existing chain's messages move.
Genesis-gated; nothing changes on a chain without the section (every chain through 18).
Action::SlashEquivocation { first, second }(variant 29, appended last): twoSignedHeader { header, signature }of one key for one view.tx_jsonrenders it as{ "kind": "slash_equivocation", "offender": <address>, "view": n, "first": { "hash", "height" }, "second": { "hash", "height" } }.rand_getValidatorsrows gainjailed_until(the first epoch the key may be in a set again;18446744073709551615for good;nullwhen not jailed).rand_getLimits.slashing:{ "equivocation_bps", "jail_epochs" }ornull.
rand_status.testnetandrand_getLimits.testnet(bool): whether the genesis says"testnet": true. A genesis withfaucet: truebeside abridgesection is refused on any chain id past 18 without it, so a client can label a chain by this field alone;falseon every chain through 18, whose files predate the marker.
Genesis-gated; nothing changes on a chain without the flag (every chain through 18).
Action::AdmitValidator { candidate, signatures }(variant 28, appended last): the validator set's vote to admit a key to the register — bundle-less, fee-less, pooled like a bridge governance action.tx_jsonrenders it as{ "kind": "admit_validator", "candidate": <address>, "candidate_key": <hex>, "voters": [<address>, …] }.rand_getAdmitted(public listener too): the admitted set with the flag and the bound.rand_getLimits.admission_by_vote(bool): whether a registration needs the vote.- Under the flag
rand_sendTransactionrefuses a registeringBondwhose key is not admitted withstaking: validator … has not been admitted by the validator set's vote(not permanent — a vote one block later makes it valid).
Additive. Every header rand_getBlocks returns gains public_notes: the block's bridge_attest,
token_mint and register_token transactions as [{ "hash": hex, "raw": hex }] (the same
encoding rand_getRawTransaction serves), empty for every other block. A wallet's first sync
reads the header pages and nothing else on such a node; on an older node it still fetches each
block with a transaction. The rand wallet also waits out a rate-limit refusal (-32005, or a
-32000 that says rate limited / busy) with doubling waits before giving up, and saves the scan's
progress whether or not it finished, so an interrupted first sync resumes.
Node-only. Nothing changes for a client of --rpc except the two read limits.
--public-rpc <ADDR>: a second listener with a fixed method set, no batches, no WebSocket and one meter for all callers — see "Two listeners" above. A public endpoint must forward to this port, not to--rpc.- Read limits, both listeners: a blocking read that has not returned within 30 s is answered
-32000("took longer than 30 s … retry with a smaller range");rand_getBlocksends its page after 64 MiB of block reads, so a page can be shorter than the range asked — advance from the last header returned, as at the head. --rpc-viewing-token-file: an optional bearer token on the three viewing-key methods.- A removed viewing key's matched notes and memos are wiped with the key, and a viewing method's request parameters are wiped once it has run.
Node-only, additive. On a chain with bridge rules v2 (chains 15–18) a deposit is judged against a rolling window per backing and one for all backings together; the UTC-day counter bounds nothing there, and the RPC went on serving it.
- Every backing row (
rand_getAssets,rand_getBridgeState.assets[], thebackingsofrand_getToken/rand_getTokens/rand_getTokenSupply) gainsminted_in_window(decimal string;nullon a day-counter chain),mint_window_secs(number;nulllikewise) andmint_headroom(decimal string): the largest deposit to that backing the caps admit at the head's block time — the per-backing cap less what it has minted, and under rules v2 no more than the registry-wide window has left. Readmint_headroom, notmint_cap_per_day − minted_today: the second ignores the global cap. minted_todayis, as its description always said, the figure the per-backing cap is checked against — under rules v2 that is now the rolling-window count, not the day counter (which read zero after midnight while the ledger still counted the deposit).mint_dayis unchanged.rand_getBridgeState.rules_v2gainsglobal_minted_in_windowandglobal_mint_headroom(decimal strings).
2026-09-30 — audit v6: a vesting revoke pays a pinned treasury, signed by a threshold (STAKE-3; genesis-gated, on no chain)
No chain has carried a vesting section, so nothing a client reads today moves.
rand_getVesting:revoker(one address ornull) is replaced byrevokers(addresses, in the order a revoke's signer indices count them;[]for an irrevocable entry),threshold(how many of them a revoke needs;0when irrevocable) andtreasury(therand1…address a revoke pays, the only one it may;nullwhen irrevocable).revocableis unchanged.revoke_vestinginrand_getTransactiongainssigners: the positions, in the entry'srevokers, of the keys that signed it. The action's wire form changed (onesignaturebecame a list of(index, signature)), which no chain has ever admitted.- Refusals a submitter may hear (all permanent — they are the transaction's bytes against the
entry's genesis terms):
a revoke pays the entry's treasury, not the address it names,N revoker signatures, the entry needs M,revoker I signed twice,revoker index I is not in the entry's list.
2026-09-30 — audit v6: price ceilings and the paying byte load (POOL-2; genesis-gated, chain 18 unchanged)
rand_getLimitsgainsmax_gas_price,max_byte_price(decimal strings; the ceilings the dynamic controller never lifts a price over) andbyte_load("paying"when only a call's proof and input envelope movebyte_price). All three arenullon chain 18 and on every chain withoutgas.dynamic— a wallet's two-step headroom (docs/fees.md§1.2) is unchanged, and under a ceiling it simply never needs more than the ceiling.- Node policy, every chain: while a call is pooled and pays its floor, the proposer holds
flat-fee transactions to three quarters of a block's bytes so the call is offered
(
Mempool::candidates_within); a submitter sees nothing of it but a call that lands.
rand_getVestinggainsrevoke_nonce— the counter arevoke_vestingsigns over, moved only by revokes;noncestays the holder's (claims, bonds, unbonds) — andlocked_now(what is still locked for the holder;"0"once revoked).unvested_nownow reads, after a revoke, what is still in the register for the treasury (a later revoke sweeps it) rather than"0".- A revoke takes the whole unvested part as of its block (the schedule stops at
revoked_at); itsunvestedfield is the amount its note carries, at most what is unvested. A revoked entry admits furtherrevoke_vestings until that rest is paid out;the entry is already revoked and its unvested part paid outis the refusal after that.
Task B4 + the RPC half of B7. Spec docs/superpowers/specs/2026-09-28-gas-model-design.md §3.3,
§7.1, §8. Genesis-gated (chain 18) — a chain without a gas section is unaffected, and its
rand_getLimits/rand_estimateFee behave exactly as Phase 0 above.
rand_getLimitsreports the chain's owngassection, not just a node's policy, when its genesis carries one:gas_price/byte_priceare the chain's current prices (the tip's, undergas.dynamic— moving per block by fullness — else the section's fixed ones),gas_meteringis"circuit", and two new fields appear —bundle_gas_limit(the bundle guest's flat declared gas,nullwithout a section) andadjust_bps(the dynamic controller's step size in basis points,nullwithoutdynamic). A chain's own section always wins over a node's--gas-price/--byte-priceflags, which are silently ignored on such a chain (onewarn!at node startup).rand_estimateFee's call spec gains a fourth field,gas— the transaction's declared gas limit — required on a chain with agassection (-32602, "under the gas section a call estimate needs its gas", without it); the answer iscircuit_call_floor(gas_price, byte_price, gas, bytes)=BUNDLE_BASE + gas_price·gas + byte_price·⌈bytes/1024⌉at the current prices (randprotocol_core::gas::circuit_call_floor).keccak_log_height/sha256_log_heightare accepted but unused under a section. A chain with no section is unaffected:gasdoes not exist and the answer is Phase 0's header-priced floor, as before.rand_statusgainsgas_prices: { "gas_price": "…", "byte_price": "…" }, the tip ledger's current prices (decimal strings),nullon a chain without agassection. Refreshed every commit by the node loop'spublish_status, the same as every other status field — this is whatrand_getLimitsreads to serve adynamicchain's current prices rather than the genesis snapshot taken at node startup.
Spec docs/superpowers/specs/2026-09-28-gas-model-design.md §4.1. Node policy, not a chain rule —
rand-node run carries the policy by default (--gas-price 100 --byte-price 800); only
--gas-price 0 --byte-price 0 turns it off and answers as before.
rand_getLimitsgains three fields:gas_price,byte_price(this node's--gas-price/--byte-price, units of 10⁻⁹ RAND, defaults 100 and 800, as decimal strings —"100","800"— like every amount;nullwith no policy) andgas_metering("header"under a policy,nullwithout one; Phase 1's chain 18 answers"circuit").rand_estimateFee's call spec gainskeccak_log_height,sha256_log_height(optional,0..=40, default 0,-32602outside the range): under a policy the answer is the gas floor of that header,max(BUNDLE_BASE + call_fee(tier, bytes), BUNDLE_BASE + gas_price·gas_max + byte_price·⌈bytes/1024⌉); with no policy the heights are accepted but the answer is the ledger floor alone.- A pool refusal a submitter may now hear on a node running a policy:
FeeTooLowfor a call under its gas floor — not a permanent verdict (rand_getTransactionStatusstill readsunknownonce it ages out of the pool), and never a block rule: a block carrying a cheaper call is still valid on every node.
v0.6.3 — split authorisation: hc_auth, the bundle's auth fields, rand-txid-3 (genesis-gated; chain 17+)
rand_status,rand_getVersionandrand_getLimitsgainhc_auth: the genesis auth guest, hex, ornullon a chain without split authorisation. Set,hc_bundleis bundle guest v3 and a wallet builds v3 transactions (nk+ salt to the bundle guest, its own auth proof over the spend key).rand_getTransaction'sbundlegainsauth_commit(hex, the publicc = H(AUTH, nk, salt); zeros on a pre-v3 bundle) andauth_proof_bytes(the auth proof's length;0on a pre-v3 bundle).- Every transaction id changes (domain
rand-txid-3, which bindsauth_commitand the auth proof's digest), on every chain this build runs; the bundle wire gains the two fields too. This build therefore refuses chains 14–16 at startup and runs chain 17 on; a client that recomputes ids locally must move with it. New permanent refusals:AuthUnexpected,AuthMissing,AuthMismatch,InvalidAuthProof. - The
Bundlewire changes (a breaking change for every encoder): two fields appended afterproof,auth_commit: Word8andauth_proof: Vec<u8>("The transaction on the wire"). bincode is positional, so no chain-16 bundle decodes under v0.6.3, nor does a v0.6.2 node read a v0.6.3 transaction as the one that was sent — the reason chains 14–16 are refused (node::CHAINS_THIS_BUILD_CANNOT_RUN). Without genesishc_authboth fields must be zero and empty (AuthUnexpected); with it, every bundle carries an auth proof, self-proved or delegated alike (docs/shielded.md§2). tx_json—rand_getTransaction'stxand each entry of a block'stransactions(rand_getBlockByHeight,rand_getBlockByHash) — carries the bundle'sauth_commitandauth_proof_bytes, so an explorer needs no core types to show them: randscan's/transactionsreads bundles throughtx_json(it does not vendor core) and gainsauth_commit/auth_proof_byteswith no decoder change.- The delegated prover (
docs/prover.md, its own listener, never this RPC) gainsprover_info.fee(null, or{amount, address}with the amount in RAND base units as a decimal string) and the error-32006 the prover fee is not paidonprover_submit; itswitness_kindsalways includesviewing_key(bundle guest v3).
rand_getLimitsgains a seventh field,hardening_v6:falseon every chain to date.truemeans 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 asPublicValues.- Pool refusals a submitter may now hear on every chain (policy, never cached, never a ledger
rule without the flag):
ProgramUncallablefor a deploy no call can hold (CPU-1), andNonCanonicalProoffor a bundle or call proof whose header the honest prover would not write (INT-5, VERIFIER-1/-2).
- Four bundle-less actions —
claim_vested,revoke_vesting,bond_vested,unbond_vested(Action24–27,docs/vesting.md).rand_getTransactionrenders each with its entry id (hex), amount as a decimal string, nonce and, for a claim or a revoke, the note'stime; 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_getSupplygainsvesting_issued,vesting_released,vesting_in_register,vesting_locked(all"0"without the section);invariant_holdscovers the register.
rand_getBridgeStategainsmin_inbound_sequence: the genesisbridge.min_inbound_sequence, an object of decimal chain ids to plain-number sequences, ornullwithout one. Abridge_attestwhose transfer carries a lower sequence from that chain is refusedBelowReplayFloor— a permanent verdict (rand_getTransactionStatusreadsrejected). Nothing changes on a chain whose genesis has no floor (docs/bridge.md§23).
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_getLimitsgains a sixth field,envelope_bytes:nullon every genesis without it (chains 14 and 15 and every earlier chain — wallets seal today's legacy 1 348-byte envelope and no memo), or1860when 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_checkTransactionandrand_getViewingNotes's disclosednoteobjects gainmemo:nullfor no memo or a memo field that opened malformed — a memo can be present on any chain, a chain withoutenvelope_bytesincluded (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
randCLI (not an RPC change): one amount convention.rand send --asset <token>,rand token mint --amountandrand token burn <ASSET> <AMOUNT>all read the amount in the asset's display units, at itsrand_getTokensrow's owndecimals(RAND at nine) — the same units arandpay:link'samountcarries. All three took whole smallest units before; a script passing1000000for 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 refusedTxError::EnvelopeSize { expected, got }, a permanent verdict.
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.
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).
Callproofs above tier 14 (MAX_CALL_TIER), with a keccak table above 2^12 or a sha256 table above 2^13, with aprogram_log_heightother than the deployed program's, or aninput_log_heightabove the tier's bound are refusedinvalid proofwith 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-20approve) 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, aRegisterTokeninitial mint and aBridgeAttestwith such anamountanswerToken(AmountTooLarge)/Bridge(AmountTooLarge)/AmountTooLargebefore any proof is read, permanently.rand_getTokensservesbound_note_value(falseon chain 14) besidemax_tokensandburn_registration_fee. - Connection caps, and a
Statusfrom 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 gossipedStatusis 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,Ignoreover that, exactly the transaction bucket's shape), sorand_status.peer_countno longer grows with the authors of relayed gossip.
tokens.burn_registration_fee(genesis-gated, audit v5 TOK-2; not on chain 14). Under it aRegisterToken's orRegisterBridgedToken'sregistration_feeis burned instead of paid to the block's proposer, who keepsfee − registration_fee(docs/tokens.md§15).rand_getTokensgainsburn_registration_fee(a boolean,falseon chain 14) andrand_getSupplygainsregistration_fees_burned(a decimal string,"0"on chain 14), whichinvariant_holdsnow subtracts on the right of the identity besideslashed. 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_QCSkeeps 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'sjustify_view, sync'scommitted_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").
rand_getSupplygainsfaucet_epochandfaucet_minted_in_epoch(decimal strings): the faucet's per-epoch counter under the genesisstakingsection (audit v4 STAKE-2,docs/staking.md§2)."0"and"0"on chain 14, which has no section. AMintover the epoch's budget is refusedFaucetBudgetExhausted— a state verdict, never cached as permanent, sorand_getTransactionStatusreadsunknown, notrejected, 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_guardiansandrotate_pause_key(rand_getTransactionkinds above; wire variants 22 and 23, appended, so every existing encoding is unchanged), under a PQ quorum of the current set overM_rotate_pq/M_rotate_pause(docs/bridge.md§21, byte for byte).rand_getBridgeStategainsrotation_nonce(what the next rotation message must carry; 0 on chain 14) andrules_v2(the group's two parameters,nullwithout 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 perrotation_nonce). rand_getTokensgainsmax_tokens(audit v4 TOK-1): the genesistokens.max_tokenscap on the registry, a number, ornullwithout one (chain 14). At the capRegisterTokenandRegisterBridgedTokenare refusedRegistryFull. The listing now pages by range fromfrom_indexinstead of scanning the registry from its first row; the reply's shape is otherwise unchanged.
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.
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.
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.
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_getHealthanswers{"status":"disk_low","free_bytes":"<bytes>"}ahead ofok/syncing/behindwhile free space is under four times the startup minimum (4 GB at the default--min-free-disk-mb 1024).rand_statusgainsdisk_free_bytes(a number, not an amount) anddisk_low.- The node refuses to start under the minimum itself, naming the directory and the flag.
Node-side only; no consensus, ledger or wire change (audit v3, VK-1/2/3).
rand_importViewingKey,rand_getViewingNotesnow answer a loopback caller only, and refuse anyone else with-32000"… answers loopback callers only on this node". Run the node with--rpc-viewing-opento 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.
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, andrand_getBlockByHeight/rand_getBlockByHash's.transactions[], same shape).action.amountforkind=mint,bond,unbond,withdraw,bridge_attest(same three methods).action.amountandaction.relayer_feeforkind=bridge_burn(same three methods; never live on a bridged public chain before this change).lockedinrand_getAssets's rows andrand_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).
rand_getBlocks's cap is 1024 headers (MAX_BLOCK_HEADERS), up from the 128 it shared withrand_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
randwallet now callsrand_getGenesisHashat the start of every scan and binds its note store (<key>.notes.json, newgenesisfield) 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 reporting0 RAND, 0 notes. A node without the method (before v0.3) can no longer be scanned against. - The
randwallet speaks TLS:--rpc https://rpc.randprotocol.orgworks. It was built without a TLS backend and refused every https URL before connecting.
- New methods:
rand_getTokens(the whole registry, paged),rand_getToken(one token by index, hex orrpl1…) andrand_getTokenSupply(supply and each backing's locked amount).rand_getAssetsis unchanged. - Token ids have a text form,
rpl1…(bech32m, HRPrpl, 62 characters):id_texton every token row, and accepted wherever a token is looked up. Hex stays accepted. rand_getTransaction:token_mintgainsr;register_tokengainsinitial({ amount, recipient, time, r }ornull). Existing fields are unchanged.rand_checkTransaction: also opens aTokenMint's envelope (output:token_mint) and a registration's initial-mint envelope (initial_mint).- Pool: two transactions at one token's
mint_nonce(aTokenMintor aSetAuthority) 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.
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).
- 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'schain,tokenand sourcedecimals,nonce,asset_id,pq_signers) — a newBridge-authority token at the next index, eight decimals on Rand, under the genesismint_cap_per_day; its fee owes the bundle base plusregistration_fee— andlist_backing(token_index,chain,token,decimals,nonce,pq_signers), owing the base. The quorum signsM_register/M_list(fixed big-endian layouts,docs/superpowers/specs/2026-09-19-bridge-hardening-design.md§9) at the bridge'slist_nonce, which both bump. rand_getBridgeStategainsregistration_feeand dropsnext_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 offassets(rand_getAssets) orrand_getTokensinstead.- 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, …) underbridge:.
rand_getBridgeStategainsmint_paused,pause_nonce,list_nonceandpause_key(hex).rand_getAssetsrows (andrand_getBridgeState.assets) gainmint_cap_per_day,minted_todayandmint_day.- Two bundle-less, fee-less actions:
pause_mints({ "kind": "pause_mints", "nonce" }, the genesis pause key's signature overb"rand-bridge-pause-1" ‖ chain_id u64 BE ‖ nonce u64 BE) andunpause_mints({ "kind": "unpause_mints", "nonce", "pq_signers" }, a PQ guardian quorum overb"rand-bridge-pq-unpause-1" ‖ chain_id ‖ nonce). Both carry the bridge'spause_nonceand 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 ownalready paused,not paused,wrong pause nonce.the pause signature does not verifyis 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'sbundle.nullifiers,commitmentsandenvelope_lenhave four elements;rand_getCompactBlockslists four notes and four nullifiers per bundle; the tree and the nullifier set grow by four per bundle. - No
asset, three burn fields.bundle.burnandbundle.assetare gone;bundle.burn_a,burn_randburn_assetreplace them (seerand_getTransaction). A transfer of any asset is"kind": "none": thetoken_transferkind andAction::TokenTransferno longer exist. - One bundle per transaction.
bridge_burnhas noasset_bundle;token_burnburns through the bundle'sburn_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:outputisbundle:0…bundle:3;asset_bundle:*is gone.rand_sendTransaction's body cap counts four envelopes: 8 867 840 bytes on a default chain.
A hard fork (the Mint wire format), for the next chain cut; nothing else in this list changes a
format.
Action::Mintgains the note's opening,pk,timeandr. Admission refuses acmthat 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 holdstimeto the bundle window. The minter signs under the domainrand-mint-2over 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_getWitnessesrun 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".
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_getProgramaddspublic_words_lenandpublic_digest, which isnullwithout a public input.- Receipts (
rand_getReceipt,rand_getReceipts, thereceiptstopic) addh_pub. It isnullwhen the proof was checked against the empty public input. rand_getTransactionand the blocks: a deploy action addspublic_words_len.rand_estimateFeetakespublic_wordsfor a deploy andbytesfor 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.
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.)
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, eachcommitted/pending/rejected(with the refusal reason) /unknown.randprotocol_client::wait_for_transactionnow uses it and fails fast onrejected, 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-floorlimit(default and cap 256) that never splits a height.rand_getWitnesses(indices)—rand_getWitnessfolded 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 minustransactions.rand_getFinality(height_or_hash)—committed/certified/proposed/unknownfrom 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 (nullwithout anaggregationgenesis section, which chain 12 lacks), and the faucet flag.- Two new WebSocket topics,
receipts [program_id]andtransaction <hash>, besidenewHeads: see Subscriptions for their shapes, the once-then-gone behaviour oftransaction, 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".
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_submitAggregateisrand_sendTransaction: anAggregateis 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_getBlockByHashgainsealed(every bundle in the block covered) and a per-transactionsealed_by(the covering aggregate's hash,nullwhile 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, andn— the subsidy schedule's index the block minted at — plus itsheight.nullfor any other transaction.subsidyis what the aggregate minted:subsidy(n), or under the genesisfees.subsidy_net_of_feesits shortfall overproving_share(docs/fees.md§1.3), sosubsidy + proving_shareis always the payout note's amount. All three numbers are the payment the ledger made while applying the aggregate, stored as it committed — not recomputed from the database, which on a node syncing several blocks in one commit missed a cover committed in the same batch (fee feedback, 2026-10-05). Because the record has no fallback, storage refuses to commit a block whose aggregates do not each carry exactly one such payment record (an error naming the height and the aggregate's hash) rather than land a sealed block for whichrand_getAggregatewould have answeredsubsidy,proving_shareandnas zero (#133).rand_getAggregatorslists the register (public by design):address,bond,payout,nonce,unbondingper 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 },excessin units over the floor: the daemon's work list. Since 2026-09-28 (IFACE-7)excessis the ledger's own bucket entry — undertokens.burn_registration_feea token registration's isfee − registration_fee − BUNDLE_BASE, notfee − BUNDLE_BASE— and a bundle with no bucket entry is not listed;rand_getAggregate'sproving_shareis summed the same way.rand_getRawTransaction(hash)returns the full transaction, bincode as hex — the proof bytes an aggregator needs andtx_jsondeliberately never renders.rand_getSupplygains the four counterssubsidised,sealed_blocks,aggregator_bonds,slashed(reported separately fromfaucet_minted, so the schedule is auditable againstsealed_blocksdirectly).rand_statusgainsaggregation: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_baseis a decimal string, like every other amount this RPC serves (2026-09-20); the rest of this object is plain integers.tx_jsonrenders 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) andrand-node aggregate [--watch], the aggregate daemon: pollrand_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-proofskeeps sealed bundles' raw proofs for archives; by default the pruning pass rewrites their records once the window passes.
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'snk(64 hex); re-import of a held key is a no-op.rand_statusgainsviewing_keys.rand_getViewingNotes(viewing_key, [from_index, limit])lazily scans from the rescan floor (at most 10 000 leaves per call, withscanned_index/next_index/completefor progress) and pages matched notes — received rows with their nullifier and spent state, sent rows (opened throughovk) without.rand_checkTransaction(hash, key)is the Monerocheck_tx_proofshape: stateless, one call, no key retention — what the given per-transactionTxKeydiscloses about the committed transaction, each opened note bound to its on-chain commitment by the AEAD. A wrong key returns an emptydisclosedlist, indistinguishable from a transaction that discloses nothing.
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
idmember — a JSON-RPC notification — is refused with-32600, batched or not; an explicit"id": nullis still a normal request. - A WebSocket endpoint on the same port (
/and/ws) is new, serving one subscription,newHeads, throughrand_subscribe/rand_unsubscribe: one notification per committed block, in order, inrand_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 (code1008), not buffered; the recovery is to reconnect and fill the gap withrand_getCompactBlocks. rand_statusgains three fields:ws_clients,refused_cacheandverify_queue, beside the four sync fields (sync_inflight_age_ms,sync_failures,sync_late_batches,connected_peers), which are unchanged.-32600is 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.