Use this page to pick the right library when you interoperate with the chain, to check a parameter against what the node enforces, and to recompute an id or a commitment yourself. Every value here is the one the code runs on chain 21 (fullnode v0.8.0, tag c0878c3f, live since 2026-10-12); where chain 21 changed a value, the chain-20 one is kept beside it.
Primitives and where they are used
| primitive | parameters | used for |
|---|---|---|
| Dilithium2 | 1,312-byte public key, 2,420-byte signature; key pairs derived from a 32-byte seed | validator votes and block proposals; faucet mints; Unbond, Withdraw and a validator’s Registration; the votes of an AdmitValidator (since chain 20); the signers of a multisig account and the validators’ SetRandPrice vote (chain 21); the aggregator actions; an RPL token’s Key mint authority (TokenMint, SetAuthority); the bridge’s post-quantum guardian co-signatures, its pause key and, since chain 20, the new keys’ possession proofs in a rotation (below). On chain 21 a private multisig account’s up to 16 Dilithium2 keys sign inside a proof, over rand-msauth-1 ‖ binding, and the chain never sees them |
| ML-KEM-768 | 1,184-byte encapsulation key, 1,088-byte ciphertext | sealing the per-transaction key of a note envelope to the receiver’s address; sealing a call-input envelope to an auditor; sealing a proving job to a paired prover |
| ChaCha20-Poly1305 | 32-byte key, random 12-byte nonce prepended, 16-byte tag | every envelope part: note bodies, wrapped transaction keys, call-input transcripts; a paired prover’s jobs and replies |
| BLAKE3 | 32-byte output, always with a domain string prefixed | addresses, transaction ids, the transaction binding and the call binding, program ids, token asset ids, the state root and its Merkle trees, the p2p identity, signing messages, bridge asset ids, a bridge deposit’s blinding and the blindings of the bridge fee notes |
| Poseidon2 over Goldilocks | width 8, rate 4, capacity 4, padding-free overwrite sponge; message elements are 32-bit words; the 86 round constants are a literal table in the node source (poseidon2_constants.rs) since the chain-15 cut, the same values the seeded draw produced before | everything inside a proof: keys, note commitments, nullifiers, the commitment tree, the program digest hc, the input commitments H_IN and H_PUB, the bundle digest, the authorisation commitment |
| secp256k1 ECDSA + keccak256 | low-s signatures, Ethereum-style 20-byte guardian addresses, mu = keccak256(keccak256(body)) | bridge guardian attestations, so the same key works on the EVM contracts, the Solana program and Rand; on Rand every attestation also needs the Dilithium2 quorum below |
| bech32m (BIP-350) | human-readable part rpl, 32-byte payload, 52 data characters + 6 checksum characters = 62 | the text form rpl1… of an RPL token’s asset id |
| Plonky3 batch STARK | Goldilocks p = 2⁶⁴ − 2³² + 1, hiding FRI, blowup 8, folding arity 8, 80 queries, 20 proof-of-work bits | every bundle proof, auth proof and call proof; a bundle proof and its auth proof carry their transaction’s eight binding words as their public input segment |
The whitepaper’s target parameters differ in places from what the implementation runs (for example the paper’s 384-bit transcript hash against the zkVM’s Poseidon2 transcript). These pages describe the code.
Post-quantum co-signatures on the bridge
The bridge’s guardian attestations are secp256k1 ECDSA, which a quantum adversary could forge. Since chain 14 every BridgeAttest — a deposit and a guardian-set rotation alike — must also carry a quorum of Dilithium2 signatures by the same guardians over the same digest:
M = "rand-bridge-pq-cosign-1" || rand_chain_id (u64 BE) || mu // 63 bytes, deterministic signing
where mu = keccak256(keccak256(body)) is what the ECDSA quorum signs. The Rand chain id in M keeps a testnet co-signature from verifying on another Rand chain even if guardian keys were reused. M binds the chain id only, on chains 20 and 21 as well: it is the one signed message binding_domain did not move behind the genesis hash, because the guardian daemons that sign it live in another repository. A copy of a chain under the same chain id with the same guardian set would accept its deposits’ co-signatures; the guardian-set index is not in M, so a co-signature survives an ECDSA-set rotation. The action’s pq_signatures field is a list of PqSignature { index: u8, signature }, checked cheap before expensive: quorum(n) ≤ len ≤ n (PqNoQuorum), indices strictly increasing (PqIndexOrder), every index below n (PqIndexOutOfRange), every signature exactly 2,420 bytes (PqBadSignatureLength), and last every signature verified under pq_guardians[index] (PqBadSignature). The PQ signers need not be the ECDSA signers; each quorum is counted on its own. The field is inside the transaction binding, so it cannot be stripped or swapped on a copy.
The PQ set is the genesis bridge.pq_guardians — eight 1,312-byte keys on chain 21, as on chains 15 to 20, index-aligned with the genesis ECDSA set, guardian set 1 (chain 14 had six, aligned with set 0). An ECDSA guardian-set rotation never moves it. On chain 14 it was fixed for the chain’s life; since chain 15 the genesis carries the bridge’s rules_v2 group, under which a PQ quorum can replace the whole PQ set (RotatePqGuardians) or the pause key (RotatePauseKey), each under the bridge’s rotation_nonce. Chain 20’s genesis added bridge.rotation, which chain 21 carries: a rotation must be the V2 action, carrying each new key’s own Dilithium2 signature over the same message (proof of possession), and it takes effect a day after it lands unless the current pause key cancels it (Bridge). rand_getBridgeState serves the keys and a decoded bridge_attest lists pq_signers, the indices that signed. The same PQ quorum authorises the bridge’s governance messages, each a fixed big-endian layout carrying the chain id and a nonce. Since chain 20 (genesis binding_domain: 1) each domain’s version digit is 2 and the 32-byte genesis hash sits between the domain and the chain id; chains 14 to 19 use the -1 forms:
| message | domain (chains 14–19 / chains 20 and 21) | signed by |
|---|---|---|
M_pause | rand-bridge-pause-1 / -2 | the one genesis pause key (Dilithium2), which can pause and never unpause |
M_unpause | rand-bridge-pq-unpause-1 / -2 | a PQ guardian quorum |
M_list | rand-bridge-pq-list-1 / -2 | a PQ guardian quorum (ListBacking) |
M_register | rand-bridge-pq-register-1 / -2 | a PQ guardian quorum (RegisterBridgedToken) |
M_rotate_pq | rand-bridge-pq-rotate-pq-1 / -2 | a quorum of the current PQ set (RotatePqGuardians, rules_v2 chains); since chain 20 also each new key, as its possession proof |
M_rotate_pause | rand-bridge-pq-rotate-pause-1 / -2 | a quorum of the current PQ set (RotatePauseKey, rules_v2 chains); since chain 20 also the new key |
M_cancel | rand-bridge-cancel-rotation-1 / -2, with a trailing kind byte | the current pause key (CancelRotation, chains with bridge.rotation: chains 20 and 21) |
The STARK in production
The zkVM is at constraint set 9 (chain 21; chains 18 to 20 ran set 8): nine mandatory tables (program, cpu, memory, alu, range, nibble, poseidon2, input, public) plus a keccak, a sha256 and, since set 9, a modq table a proof carries only when its guest called the matching syscall, connected by buses and proved as one batch STARK. The modq table is the MODQ chip: one 1,024-row block per MODQ syscall (an NTT, inverse NTT, dot product or multiply-subtract over 256-coefficient polynomials mod Dilithium’s q = 8,380,417), declared in the proof header as modq_log_height, carried only by the multisig auth guest, and the reason every verifier key and the proof encoding moved at chain 21 (the cpu table gained the syscall’s columns). Since constraint set 7 every declared table has at least 2⁷ rows and every table’s LogUp terminal is blinded through the seventeenth bus, BLIND. A proof’s public interface is 35 field elements (pv::NUM): the entry pc, the tier, the eight output words, the program digest hc (8 words), the salted private-input commitment H_IN (8), the unsalted public-segment digest H_PUB (8) and, since constraint set 8, the declared gas limit GAS_LIMIT.
| parameter | production | note |
|---|---|---|
| FRI queries | 80 | the 27-query retune of constraint sets 3 and 4 was reverted after the 2026-09-12 audit: it left ~42 proven bits |
| blowup | 8 (log_blowup = 3) | every table’s maximum constraint degree is pinned under the cap of 8 |
| folding arity | 8 | |
| proof-of-work bits | 20 | |
| soundness | 3·80 + 20 = 260 bits conjectured (ethSTARK); about 86 bits proven under proximity gaps | |
| zero knowledge | statistical: the LDE and the FRI batch polynomial are randomised, so two proofs of one run differ | since constraint set 7 every declared table is at least 2⁷ rows, more than the 82 points a proof opens, and the LogUp terminals are blinded |
| tiers | 10, 12, 14, 16, 18, 20 | a proof reveals the tier, never the cycle count; since fullnode v0.5.6 a call proof above tier 14 is refused (CallTierTooHigh), a bundle proof is pinned to tier 14 and an auth proof to tier 10 |
| proof cap | the genesis max_proof_bytes: 6 MiB (6,291,456) on chain 21, for the multisig auth proof; 4 MiB (4,194,304) on chains 17 to 20; 8 MiB on chains 13 to 16; 2 MiB (MAX_PROOF_BYTES) on a genesis without it, such as chain 12 | 2 MiB is above every hash-table-free proof the deployed guests can produce and below a keccak-carrying one; 4 MiB admits both |
| gas | 1 per cpu row; 3 per Poseidon2 absorb row; 64 per SHA256 row; 192 per KECCAK row | accumulated in the cpu table’s GAS column and proved at most GAS_LIMIT; the verifier refuses a limit above gas_max(t, k, s) = (2ᵗ − 1) + 2ᵗ⁻² + 191·(2ᵏ/32) + 63·(2ˢ/64), which is 1,279 for a hash-free tier-10 header and 20,479 for a tier-14 one |
Measured sizes at constraint set 6 (Apple Silicon, release build): a keccak-free production proof was 1,298,729 bytes at tier 10 and 1,359,978 at tier 12; a keccak-carrying tier-10 proof was 3,198,430, over the 2 MiB default and under the 8 MiB of chains 13 to 15. A tier-14 proof of the retired 2-in-2-out bundle guest measured 1,321,773 bytes on chain 8, and 1,418,406 to 1,420,423 bytes on chain 13. Chain 14’s 4-in-4-out hidden-asset bundle guest stays at tier 14 with neither hash table, and its proofs measured 1,422,116 and 1,423,170 bytes on chain 14 (2026-09-20), and chain 15 pinned the same guest (hc_bundle unchanged); a bundle proof declared public_log_height 4 there, the height of the eight binding words, and the node refused any other value before building a verifier key. The keccak-carrying ERC-20 approve call proof made on chain 13 on 2026-09-19 (tier 16) is 3,412,405 bytes; every chain since fullnode v0.5.6 refuses that call, at tier 16.
Constraint set 7’s blinding columns and table floors add about 5% to a hash-free proof: measured by the fullnode on 2026-09-28, fib is 1,367,688 bytes at tier 10 and 1,424,299 at tier 12, a keccak-carrying tier-10 proof 3,283,898, and a tier-14 hidden-asset bundle proof 1,498,821 (guest v1) and 1,497,156 (guest v2). A bundle proof declares public_log_height 7 since then. Constraint set 8 adds under 1%. Measured on chain 18 on 2026-09-30 (Apple Silicon): auth proofs of 1,364,940 to 1,375,371 bytes (tier 10), bundle proofs of 1,494,151 to 1,504,260 bytes (tier 14, guest v3), and call proofs of 1,498,589 bytes at tier 14 with no hash table, 1,911,152 at tier 14 with the SHA-256 table and 3,346,865 at tier 12 with the Keccak table. All fit the 4 MiB cap of chains 18 to 20.
Verification is about 16 ms with a warm verifier key and about 230 ms the first time a (tier, heights) key is built. The test FRI profile (16 queries) exists for the test suite and is insecure; chain 21’s genesis pins production, as chain 20’s did. Fullnode v0.6.8 changed no constraint: chain 20 pinned the same hc_bundle and hc_auth as chains 17 to 19. Fullnode v0.8.0 (chain 21) keeps both digests — hc_bundle 60af094a…6e3fce, hc_auth 1e4e347f…5639c1 — and adds a third, hc_auth_multisig 9705f8e2f1bc8561848b29cb8efd57c37e3e39788fa58a5f03304d8019668247, the multisig auth guest at tier 18 (up to 3 of 16 Dilithium2 signatures checked in circuit; its pinned verifier key, msauth-vk-production.bin, 23,754 bytes, SHA-256 68facb06…cd30, is loaded by every validator at startup); under constraint set 9 every verifier key is new although the digests are not.
Hash constructions you can reproduce
BLAKE3 with a domain: blake3(domain || data), the domain string with no separator.
The binding domain. Chain 20’s genesis set binding_domain: 1 (fullnode v0.6.8, audit v6 BIND-1), and chain 21 keeps it. On such a chain every proof binding and every signed action message that bound the chain id alone now binds the genesis hash too: the domain’s version number goes up by one and the 32-byte genesis hash goes first in the preimage, before the chain id. A proof or a signature made for one chain then verifies on no other, even one that reuses the chain id and the keys, as a re-cut does. The binding words are a public input of the proofs, so no guest or verifier key changed. The table gives both forms; chains 14 to 19 use the first, byte for byte. Already genesis-bound before, and unchanged: votes and proposals (since consensus_domain: 1), the v2 Registration, and an AdmitValidator vote. Not moved: the bridge’s mint co-signature (above). A wallet needs v0.6.8 to send on chain 20 and v0.8.0 on chain 21, and decides the form by chain id, not by what a node claims; rand_getLimits.binding_domain reports it.
| value | construction |
|---|---|
| transparent address | blake3("rand-address" || dilithium2_public_key), base58 |
| transaction id | blake3("rand-txid-3" || bincode(view)), where view is the transaction with the bundle’s proof bytes replaced by blake3(proof) and its auth proof by blake3(auth_proof); auth_commit enters as is. The domain was rand-txid-2, with no auth fields, through chain 16 |
| transaction binding | blake3("rand-tx-bind-1" || bincode(chain_id, bundle', action')) on chains 14 to 19; blake3("rand-tx-bind-2" || bincode(genesis_hash, chain_id, bundle', action')) since chain 20. Eight little-endian u32 words, where ' replaces bundle.proof, bundle.auth_proof and an Aggregate’s proof by the empty vector; bincode with fixed-width little-endian integers. Every bundle proof and auth proof is made over and verified against these words |
| call binding | blake3("rand-call-bind-1" || …), or rand-call-bind-2 with the genesis hash first since chain 20: the transaction binding’s construction with the Call’s (or Invoke’s) own proof blanked too. On a hardening_v6 genesis, such as chains 20 and 21, a call proof’s public segment is the program’s deploy-time public words followed by these eight words, and an Invoke’s adds its transition as context words after them |
| program id | blake3("rand-program" || base_pc as 4 LE bytes || words as 4 LE bytes each) |
| p2p identity | ed25519 key from blake3("rand-p2p-identity" || seed) |
| bridge asset id (per backing) | blake3("rand-bridge-asset" || token_chain as 2 BE bytes || token_address (32 bytes)) — rand_bridgeAssetId computes it and rand_getAssets prints it per backing row; it identifies a source coin on the wire, not the token |
| RPL token asset id, native | blake3("rand-rpl-asset" || bincode(name, symbol, decimals, authority, initial: Option<InitialMint>, salt)), salt a creator-chosen 32 bytes; shown as hex and as rpl1… |
| RPL token asset id, bridged | blake3("rand-rpl-asset" || bincode(name, symbol, BRIDGE_DECIMALS, salt)) — not over a (chain, token) pair, since one bridged token spans several backings |
| bridge deposit blinding | r = blake3("rand-deposit-r-1" || mu), the only r admission accepts for a BridgeAttest (WrongDepositBlinding otherwise) |
| bridge fee note blindings (since chain 20) | a deposit’s fee note: r = blake3("rand-bridge-fee-r-1" || mu); a burn’s: r = blake3("rand-bridge-burn-fee-r-1" || txid); both read as eight little-endian words. The notes have no envelope: every field is public, the owner being the genesis bridge.fees.recipient |
| bridge recipient | blake3("rand-shielded-recipient" || pk_bytes || kem_ek) over the full shielded address |
faucet Mint message | blake3("rand-mint-2" || bincode(chain_id, cm, pk, time, r, envelope, amount)); since chain 20 blake3("rand-mint-3" || bincode(genesis_hash, chain_id, cm, pk, time, r, envelope, amount)) |
TokenMint, SetAuthority messages | blake3("rand-rpl-mint-1" or "rand-rpl-authority-1", chain_id || asset_id || nonce || body); a mint’s body includes blake3(bincode(envelope)). Since chain 20 rand-rpl-mint-2 and rand-rpl-authority-2, with the genesis hash first |
Registration message | blake3("rand-register-2" || bincode(genesis_hash, chain_id, validator, payout)) on a genesis with staking.registration_v2 or binding_domain: 1, such as chains 20 and 21 (a v1 registration is refused there); blake3("rand-register" || bincode(chain_id, payout)) elsewhere |
AdmitValidator vote (since chain 20) | blake3("rand-admit-validator-1" || genesis_hash || candidate_address), signed by each voting validator |
Unbond message | blake3("rand-unbond" || bincode(chain_id, validator, amount, nonce)); since chain 20 blake3("rand-unbond-2" || bincode(genesis_hash, chain_id, validator, amount, nonce)) |
Withdraw message | blake3("rand-withdraw" || bincode(chain_id, validator, amount, nonce, time, r, envelope)); since chain 20 rand-withdraw-2, with the genesis hash first |
| Merkle node (state trees) | blake3("rand-merkle-node" || left || right) |
| token registry leaf | blake3("rand-token-leaf-1" || bincode(TokenInfo)), in index order, under tokens_root |
| state root | since chain 20, blake3("rand-state-admitted-1" || inner || admitted_root) around inner = blake3("rand-state-8" || tree_root || nullifier_root || validators_root || programs_root || bridge_root || tokens_root || faucet_epoch || faucet_minted_in_epoch || bond_queue_root || gas_price || byte_price || program_state_root), where program_state_root = blake3("rand-program-state-1" || cells_root || vaults_root) (RPL-2), the two faucet counters and the two live prices are 8 big-endian bytes each, and the wrapper commits the admitted set (staking.admission_by_vote). Chain 21’s genesis sections add their own wrappers around that: rand-state-multisig-1 for the multisig accounts and rand-state-price-1 for the validator-voted RAND price, beside the perps state; the fullnode’s docs/multisig.md, docs/fees.md and docs/perps.md give the exact layering. Chain 19’s was blake3("rand-state-7" || …) over the same fields up to byte_price (a genesis staking section appends the three after tokens_root under rand-state-5, as on chains 15 to 17; a genesis gas.dynamic section appends the two prices and moves the domain to rand-state-7); rand-state-4 over the first six on chain 14; rand-state-2 over the first four on a chain with no bridge, tokens or aggregation section (Consensus) |
Poseidon2 with a domain tag: H(domain, msg) = sponge_hash([domain, msg...]), the tag the first absorbed word, each word a Goldilocks element, the 4-element digest split into eight u32 words (low half then high half of each element). Domain tags:
| tag | value | construction |
|---|---|---|
NK | 1 | viewing key nk = H(1, sk) |
PK | 2 | note public value pk = H(2, nk) |
NF | 3 | nullifier H(3, nk || cm) |
CM | 4 | note commitment H(4, pk(8) || from(8) || amount_lo || amount_hi || asset || time || r(8)) — 28 words |
OVK, KEM_SEED | 5, 6 | the outgoing viewing key and the ML-KEM seed, through a counter-mode wide hash [domain, nk, counter] |
NODE | 7 | tree node H(7, left || right); the empty leaf is all zeros |
HC | 8 | the program digest; the tag, base_pc and len sit in the capacity lanes of the first permutation, then the words are absorbed four per block |
OUT | 9 | the transfer guest’s output digest |
IN | 10 | H_IN: capacity header [10, n_in, 0], then a 4-word salt, then the inputs |
BUNDLE | 11 | the retired 2-in-2-out bundle’s digest over 47 public words; no chain pins that guest any more |
| hidden bundle | 64 | the hidden-asset bundle’s digest over 81 words on chains 14 to 16: anchor, four nullifiers, four commitments, fee, burn_a, burn_r (two words each), burn_asset, time, and the taint word last. On chains 17 to 20 (bundle guest v3) it is 89 words: the eight words of the authorisation commitment c sit immediately before the taint word. The private asset A is not in it. The tag is node-local and sits clear of the vendored tags 1 to 15 |
AUTH | 65 | the authorisation commitment c = H(65, nk || salt), 16 words, with a fresh 256-bit salt per bundle; published by the bundle proof inside its digest and by the auth proof as its eight output words, and carried by the bundle as auth_commit (chains 17 to 20). Node-local, like 64 |
PUB | 15 | H_PUB: header [15, n_pub, 0], no salt; for a program, since chain 20 the words are fixed at deploy (at most 32,768), every call’s segment is those words followed by the eight words of the call binding, and the call is checked against the digest of that segment; for a bundle proof and an auth proof the segment is the transaction’s eight binding words; a genesis without max_program_public_words admits only the empty segment |
hc, H_IN and H_PUB are not plain message-prefixed sponges: the header goes into the capacity so that the digest rows in the cpu table absorb exactly four words per permutation. Reproduce them with the vendored crate (randprotocol_zkvm::hash::{input_digest, public_digest}, isa::Program::digest) rather than by hand.
The plain sponge does not pad, so within the first block [a] and [a, 0] hash alike. Every construction above hashes a fixed-length message under its own tag. For data whose length can vary, constraint set 7 added a length-bound sponge (hash::sponge_hash_len, the POSEIDON2_LEN syscall): it starts from the state [0, 0, 0, 0, n, 0, 0, 0], n the word count, and permutes at least once. It is a different hash of the same words, and no note, nullifier, root or digest on the chain uses it.
Two properties to remember: hc is binding but not hiding (no salt, so a guessed program can be confirmed and two deployments of one program are linkable), and H_IN is hiding as well as binding (a fresh 128-bit salt per proof), so publishing H_IN reveals nothing about the inputs until the salt holder opens it.
Key derivations
| key | from |
|---|---|
| Dilithium2 key pair (node) | deterministic from the 32-byte seed |
| libp2p ed25519 (node) | blake3("rand-p2p-identity" || seed) |
viewing key, pk, ovk, ML-KEM key pair (wallet) | Poseidon2 derivations of the spend key as tabled above; the ML-KEM pair is MlKem768::from_seed(kem_seed) |
The papers on the papers page carry the security arguments: the post-quantum choices, the FRI soundness accounting and why 80 queries are kept, and the trace-length bound.