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.
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.)
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_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.
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.
Params: []. Result: the chain's five call limits and the envelope size, from its genesis:
{ "max_program_words": 4096, "max_proof_bytes": 2097152, "max_block_bytes": 4194304,
"max_call_envelope_bytes": 18432, "max_program_public_words": 0, "envelope_bytes": null,
"hardening_v6": false }Those are the defaults, what a genesis without the fields gets (chain 12). A wallet derives its caps from these instead of hard-coding them: the most words a program may have, the largest proof, the largest transaction (a block's worth), the largest call-input envelope, and the most public words a deploy may carry.
envelope_bytes is null on every genesis without the field (every existing chain), meaning no
uniform size is enforced and a wallet seals the legacy 1 348-byte note envelope with no memo. Set
to 1860 (spec 2026-09-26 §2.4), it means every note-creating envelope on this chain must be
exactly that long — a wallet seals the memo-carrying format instead, and a memo becomes readable
by the payee, the sender's own history, and anyone handed that output's per-transaction key.
There is no other value yet: validate accepts only 1860 once the field is present.
hardening_v6 is true when the genesis sets the v0.6 switch (docs/deploy.md, "The next cut:
hardening_v6"). A wallet then proves a call over the program's public input (empty for most)
followed by the transaction's call binding (Transaction::call_binding, INT-4; issue #55 for a
program with a public input) instead of the public input alone —
the fee bundle's notes chosen first, the call proved second, the bundle last; a chain with the flag
refuses the old proof, a chain without it the new one. A node that predates the field answers
without it, which a wallet reads as false.
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} (t one of 10, 12, 14, 16, 18, 20). Result: the minimum fee
in units, as a string. {"kind":"bundle"} is the floor for a plain transfer: 1000000. A deploy of
more words than the chain's program cap (4096, or the genesis file's max_program_words) is an
invalid-params error (-32602) naming the cap — the same program admission would refuse, so a
wallet can ask before it proves.
public_words (optional, default 0) is the deploy's public input length. Public words are paid for
per word like code, so the fee is the deploy fee of n + m words. More than the chain's
max_program_public_words (0 by default) is -32602, naming the cap, in the same shape.
Anything but a non-negative integer (or null) is -32602 as well.
bytes (optional, default 0) is the call's proof length plus its input envelope's length. A call
at or under the free allowance (2 097 152 + 18 432 bytes) costs what it did before this field
existed; each KiB over it, a partial KiB counting as whole, adds 1000 units. Without bytes the
answer is the old one. Anything but a non-negative integer is -32602.
Params: [hash]. Result: null until committed, then:
{
"height": 192, "index": 0, "block_hash": "63f6…08",
"tx": {
"hash": "4f2c…e7", "chain_id": 7,
"bundle": {
"anchor": "6b1d…c4",
"nullifiers": ["8c04…d1", "5e77…20", "03aa…6f", "e19b…42"],
"commitments": ["2a9f…07", "b310…88", "77c1…0e", "5d20…b3"],
"fee": "1000000", "burn_a": "0", "burn_r": "0", "burn_asset": 0, "time": 5,
"proof_len": 1431562, "envelope_len": [1380, 1380, 1380, 1380]
},
"action": { "kind": "none" }
}
}bundle is null on a mint (a mint carries no bundle). The proof and the envelopes are reported
by length only; anyone who wants the bytes can fetch the block.
bundle is the hidden-asset bundle (chain 14): four input slots and four output slots, so always
four nullifiers, four commitments and four envelope_len, dummies included. It has no
asset field: slots 0–1 carry a private asset and slots 2–3 RAND, and nothing public says which
asset slots 0–1 moved. A transfer of RAND and a transfer of any RPL token are both "kind": "none" with burn_a, burn_r and burn_asset all 0 — the same shape, field for field. The
three burn fields are the bundle's only public statement about value leaving the pool:
burn_a/burn_asset— an amount of the private asset burned, and which asset. Non-zero only on 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.
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), 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,
"peer_count": 5, "connected_peers": 5, "ws_clients": 3, "refused_cache": 0,
"verify_queue": 0, "mempool_size": 0,
"is_validator": true, "active_validator": true, "faucet": true, "confidential": true,
"fri_profile": "production", "programs": 2, "viewing_keys": 0,
"notes": 41, "nullifiers": 12, "tree_root": "6b1d…c4", "hc_bundle": "f07a…19",
"address": "2nRdFC…", "peer_id": "12D3KooW..."
}syncing is true while a batch request to a peer is in flight; sync_target is the highest height
any peer has advertised. viewing_keys is how many viewing keys this node is holding for
node-side scanning (see rand_importViewingKey) — in memory only, so it reads 0 after every
restart, and anything above it is worth an operator's attention precisely because it changes what
compromising the process would disclose.
The four fields beside them are for reading a node that is behind and not catching up, which otherwise looks identical to a node that is behind and working:
sync_inflight_age_ms— how long the outstanding batch request has been outstanding, 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.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.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.
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`… ]
}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;
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 }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.
Params: [from_index, limit], both optional (0 and 1000; limit is clamped to 1000). Result:
the RPL token registry — every token, bridged and native — ascending by index from from_index,
read by range from that index (never a scan of the rows below it, v0.5.4):
{ "enabled": true, "registration_fee": "1000000000", "next_index": 3, "max_tokens": null,
"tokens": [
{ "index": 2, "id": "<64 hex>", "id_text": "rpl1…", "name": "Test Coin", "symbol": "TST",
"decimals": 6,
"authority": { "kind": "key", "key": "<Dilithium2 key, hex>", "address": "<base58>" },
"mint_nonce": 1, "total_supply": "5700", "registered_at": 3 }
] }On a chain without a tokens section: { "enabled": false, "tokens": [] }. index is the
asset word a note of the token carries (0 is RAND and is never listed). id is the token's
asset id and id_text its checksummed text form — bech32m, HRP rpl, over the 32 id bytes, 62
characters. authority is { "kind": "none" } (fixed supply, or renounced), { "kind": "key", "key", "address" }, { "kind": "bridge", "backings": [{ "chain": 2, "token": "<32 bytes hex>", "decimals": 6, "locked": "600", "mint_cap_per_day": "10000000000000", "minted_today": "1000", "mint_day": 20350 }] } (each backing's source decimals, the amount its contract holds for
this chain, and bridge hardening B1's mint-cap figures — rand_getAssets's fields, one row per
backing) or { "kind": "program", "program": "<hex>" }. Supplies, locked, mint_cap_per_day,
minted_today and registration_fee are decimal strings (RAND units for registration_fee,
token units for the rest); next_index, mint_day and max_tokens are numbers. max_tokens
(v0.5.4, audit v4 TOK-1) is the genesis cap on how many tokens the registry may hold —
registration is refused RegistryFull at it — or null on a chain whose genesis has none
(chain 14). burn_registration_fee (v0.5.5, audit v5 TOK-2) is a boolean: whether a
registration's registration_fee is burned rather than paid to the block's proposer
(docs/tokens.md §15) — false on chain 14. bound_note_value (v0.5.6, deep scan) is a
boolean: whether a mint or deposit is held below 2^63 as a validity rule (docs/tokens.md §16;
the admission screen refuses such an amount on every chain regardless) — false on chain 14.
A page shorter than limit is the last.
A wallet resolves a token id through this listing (wallet::resolve_asset), never through
rand_getToken: a transfer's asset is private on chain, and reading the whole registry costs the
same whichever token is meant.
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 }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: { "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": "…",
"vesting_issued": "…", "vesting_released": "…", "vesting_in_register": "…", "vesting_locked": "…",
"pool_value": "…", "register_total": "…", "total_supply": "…", "invariant_holds": true }The four vesting_* fields (genesis vesting, docs/vesting.md; "0" without a vesting
section): what genesis issued into the vesting register, the notes claims and revokes released into
the pool (inside pool_value), what the register still holds (inside total_supply; RAND bonded
from it is a validator's stake, so in register_total), and what of it has not unlocked yet at the
head. vesting_issued is issuance, on the identity's right beside genesis_staked.
registration_fees_burned (v0.5.5, audit v5 TOK-2) is Σ of the registration fees burned under
the genesis tokens.burn_registration_fee (docs/tokens.md §15): inside burned on the pool
side and in no register entry, so the identity below subtracts it on its right beside slashed.
A decimal string; "0" on chain 14, which has no gate.
faucet_epoch and faucet_minted_in_epoch (v0.5.4, audit v4 STAKE-2) are the faucet's per-epoch
pair: the epoch the counter is for and what the faucet minted in it, against the genesis
staking.faucet_budget_per_epoch. Both are decimal strings like the rest of this object, and both
read "0" on a chain without a staking section (chain 14), where no budget applies. On a chain
with one they are consensus state — in the state root and replayed by rand-node verify — not a
derived count like the rest.
The supply audit. Note values are hidden, but every crossing of the pool's boundary is public, so
these are exact: value enters the pool as a genesis deposit, a faucet mint or a validator's
withdraw, and leaves it as a bundle fee (into a proposer's rewards) or a burn (a Bond, into
stake). A withdraw's own base fee is not a crossing: withdraw_deposited counts the note it
created (amount less the base), and the base moves from one register entry to another.
pool_value = genesis_deposited + faucet_minted + withdraw_deposited − fees_paid − burned; register_total is Σ stake + pending + rewards over the register; total_supply is the
two together, and invariant_holds is whether it still equals everything the chain issued
(genesis_deposited + genesis_staked + faucet_minted) less what was destroyed (slashed and
registration_fees_burned). A false there is a bug, never a legitimate chain state. The counters are not in the state root — rand-node verify --mode quick recomputes
every one of them by replaying the chain, which is what makes them auditable. docs/supply.md
works the identity through a bond and a withdraw and says where it rests on a claim (the genesis
file's own amounts) rather than on a check.
Params: [id, at_ms?] — the entry's 64-hex id, and optionally the time to evaluate the schedule at
(default: the head block's timestamp). Result, genesis vesting (docs/vesting.md):
{ "id": "3f9a…", "class": "investor", "beneficiary": "<address>", "revocable": false, "revoker": null,
"amount": "18000000000000000", "start_ms": 1790000000000, "cliff_ms": 31104000000,
"linear_ms": 46656000000, "step_ms": 2592000000,
"claimed": "0", "revoked_out": "0", "revoked_at": null,
"bonded": "0", "bonded_to": null, "unbonding": [], "nonce": 0,
"vested_now": "0", "claimable_now": "0", "unvested_now": "18000000000000000", "as_of_ms": 1790000000000 }Keys are served as their addresses. claimable_now is what a claim_vested may take: vested,
unclaimed, and neither bonded nor still unbonding. null for an id the register does not hold;
{"enabled": false} on a chain without a vesting section.
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",
"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.
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.
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.
Params: []. Result:
{ "inflation": "0",
"subsidy": { "current": "250", "base": "1000", "halving_blocks": 210000,
"sealed_blocks": 420001, "next_halving_at": 630000 },
"faucet": true }inflation is a fixed "0" — nothing on this chain mints outside a genesis allocation, the
testnet faucet, or the bounded aggregation subsidy, so a client expecting Solana's
getInflationRate gets a number instead of a missing method. subsidy is the block-aggregation
schedule (gas::subsidy, the 2026-09-15 changelog entry below); it is null on a chain whose
genesis carries no aggregation section, which chain 12 does not. faucet mirrors rand_status's
field of the same name.
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
}
Envelope { kem_ct: Vec<u8>, to_receiver: Vec<u8>, to_sender: Vec<u8>, body: Vec<u8> }
Action::None // a plain shielded transfer
Action::Mint { cm: Word8, pk: Word8, time: u32, r: Word8, // cm = commitment of (pk, no sender,
envelope: Envelope, amount: u64, // amount, asset 0, time, r), checked
minter: PublicKey, signature: Signature } // by admission (audit v3, POOL-1)
Action::Deploy { base_pc: u32, words: Vec<u32>, public: Vec<u32> }
Action::Call { program: Hash, proof: Vec<u8>, // postcard(rand_zkvm::Proof)
input_envelope: Option<CallEnvelope> }
Action::Bond { validator: Address, amount: u64, registration: Option<Registration> }
Action::Unbond { validator: Address, amount: u64, nonce: u64, signature: Signature }
Action::Withdraw { validator: Address, amount: u64, nonce: u64, time: u32, r: Word8,
envelope: Envelope, signature: Signature }
Action::BridgeAttest { attestation: Vec<u8>, recipient: ShieldedAddress, r: Word8, time: u32,
asset: u32, envelope: Envelope,
pq_signatures: Vec<PqSignature> } // bridge hardening B3, last field
Action::BridgeBurn { asset: u32, amount: u64, relayer_fee: u64, to_chain: u16,
token: [u8; 32], to: [u8; 32] }
Action::RegisterToken { name: String, symbol: String, decimals: u8, authority: MintAuthority,
initial: Option<InitialMint>, salt: [u8; 32], index: u32 }
Action::TokenMint { asset: u32, amount: u64, recipient: ShieldedAddress, r: Word8, time: u32,
envelope: Envelope, nonce: u64, signature: Signature }
Action::SetAuthority { asset: u32, new: Option<PublicKey>, nonce: u64, signature: Signature }
Action::TokenBurn { asset: u32, amount: u64 }
Action::PauseMints { nonce: u64, signature: Signature } // B1, bundle-less, fee-less
Action::UnpauseMints { nonce: u64, pq_signatures: Vec<PqSignature> } // B1, bundle-less
Action::RegisterBridgedToken { name: String, symbol: String, salt: [u8; 32], chain: u16,
token: [u8; 32], decimals: u8, nonce: u64,
pq_signatures: Vec<PqSignature> } // B4
Action::ListBacking { token_index: u32, chain: u16, token: [u8; 32], decimals: u8, nonce: u64,
pq_signatures: Vec<PqSignature> } // B4
MintAuthority = None | Key(PublicKey) | Bridge { backings: Vec<Backing> } | Program(Hash)
InitialMint { amount: u64, recipient: ShieldedAddress, r: Word8, time: u32, envelope: Envelope }
Registration { public_key: PublicKey, payout: ShieldedAddress, signature: Signature }
PqSignature { index: u8, signature: Vec<u8> } // one guardian's Dilithium2 co-signature, by set index
This block does not show the five block-aggregation actions (RegisterAggregator,
UnbondAggregator, WithdrawAggregator, SlashAggregator, Aggregate) that sit between
BridgeBurn and RegisterToken in the enum's declared order — see the 2026-09-15 changelog
entry below for their fields. They occupy real bincode tags on any chain whose genesis carries an
aggregation section; a chain without one (chain 12 onward, until it returns) never admits them,
but an encoder that hard-codes tag numbers rather than deriving them from the enum still needs to
count them.
A Bond must carry a bundle whose burn_r equals its amount (and whose burn_a and
burn_asset are 0) — that is how the stake leaves the pool — and registration is present exactly when the validator is not in the register yet
(docs/staking.md).
Every transaction carries at most one bundle. A BridgeBurn or a TokenBurn burns through it:
the bundle's burn_asset must be the action's asset (never 0), its burn_a the action's
amount, and its burn_r 0, while its fee pays in RAND from slots 2–3. Every other action burns
no token (burn_asset and burn_a 0), and RAND is only ever burned through burn_r: a bundle with
burn_asset 0 and burn_a non-zero is refused everywhere. A BridgeAttest's deposit note is the one commitment the wire does not
carry — the chain computes it from the amount the guardians signed, the recipient the action
names, its blinding r, its time and the registry index it names in asset, so a submitter
cannot choose the amount or the owner. r is derived, not chosen (chain 14, F1): admission
requires it to equal blake3("rand-deposit-r-1" ‖ mu) over the digest the guardians signed
(mu), and refuses any other value with BridgeError::WrongDepositBlinding — a permanent
refusal, since it depends on the transaction's own bytes alone
(docs/bridge.md §5). A submitter can still choose time, within the window a bundle's time
gets, which is what lets the depositor seal an envelope for a note whose commitment it can compute
before knowing which block will take the transaction — and, with r derived too, two independent
submitters of the same attestation at the same time name the identical note. asset is the
index the envelope was sealed for: a bridged token is listed — at genesis or by a
RegisterBridgedToken/ListBacking governance message — before any attestation of it is
admissible, and a listing's index never moves once assigned, so there is no first-sighting
registration to race for a bridge deposit (that only ever applied to the RPL registry's own
RegisterToken, a different action). An attestation of a coin nobody has listed is refused
BridgeError::UnlistedToken before asset is even compared; once the coin is listed, a mismatch
between the index the attestation resolves to and the index the action names is
TxError::AttestAssetMismatch — the attestation deposits under asset 2, and the transaction names 1 — which only a stale registry view (a node behind the listing) or a hand-built
transaction can hit.
A Withdraw derives its note the same way and for the same reason: time is the head height when
the command ran, and the chain, not the wire, computes the commitment (docs/staking.md).
Encoded sizes (bincode's default configuration: fixed-width integers, 8-byte length prefixes,
u32 enum tags):
| part | bytes |
|---|---|
Word8 |
32 |
one Envelope |
1380 (1088-byte ML-KEM-768 ciphertext, two 60-byte wrapped transaction keys, a 140-byte sealed note, four length prefixes) |
Bundle minus the proof |
5848 (four Word8 nullifiers and commitments, four envelopes) |
| transfer transaction minus the proof | 5861 |
| deploy transaction minus both proofs, 100-word program | 6281 |
| call transaction minus both proofs | 5902 |
| mint transaction (no bundle) | 5181 (a 1312-byte Dilithium2 key and a 2420-byte signature) |
| bundle proof | 327,203 measured for the hidden-asset guest at tier 14 under the test FRI profile |
So a shielded transfer on the wire is about 1.43 MB at the 80-query production profile,
essentially all proof. The ledger caps a proof at 2 MiB by default (chains 13–15 set
max_proof_bytes to 8 MiB in their genesis; note 2026-09-28), an envelope
at 2048 bytes, a program at 4096 words (or the genesis file's max_program_words, at most 65 535),
and a block at 4 MiB of transaction bytes — two bundles
per block at that default (docs/block-space.md; a genesis may raise the caps).
A wallet builds all of this through randprotocol_client::wallet::{send, submit}, which selects the
inputs, fetches the anchor and the witnesses, proves the bundle, seals all four envelopes, and checks
the proof's published digest against the one it computed before it submits anything.
What changed for clients, in one place. Newest first.
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.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.