Use this page to build or audit anything that reads the pool: a wallet that scans for its notes, an indexer that follows commitments and nullifiers, or a tool that checks what a transaction publishes. Value on this chain is a set of notes; the chain holds their commitments, never their contents.
A note
A note is { pk, from, amount, asset, time, r }: 28 words of 32 bits, 112 bytes, laid out as pk(8) from(8) amount_lo amount_hi asset time r(8). pk is the owner’s note public value (derived from the viewing key), from the sender’s, amount a u64 in units (1 RAND = 10⁹ units), asset a u32 (0 for RAND, otherwise the token registry’s dense index for an RPL token, bridged or native; see RPL tokens), time a block height the sender targeted, and r eight random words of blinding.
The plaintext never appears on chain. What appears is:
- its commitment
cm = H(CM, words), a Poseidon2 hash of the 28 words under domain tag 4, appended as a leaf of the tree; - its envelope, the plaintext sealed to the owner’s address so that only the owner, or the sender through its outgoing viewing key, can read it. Since chain 18 (chain 21 included) the envelope also carries an encrypted memo. A few notes the chain computes from public fields carry no envelope at all (below).
Spending the note publishes its nullifier H(NF, nk, cm), a Poseidon2 hash under domain tag 3 of the owner’s viewing key and the commitment. Without nk a nullifier cannot be linked to its commitment. The nullifier set is what prevents a double spend.
The envelope
Envelope { kem_ct, to_receiver, to_sender, body }
| part | bytes | what it is |
|---|---|---|
kem_ct | 1,088 | an ML-KEM-768 encapsulation to the receiver’s address |
to_receiver | 60 | the per-transaction key TxKey wrapped under the encapsulated secret |
to_sender | 60 | the same TxKey wrapped under the sender’s outgoing viewing key ovk |
body | 652 | the 112-byte note and a 512-byte memo field under TxKey (140 bytes, the note alone, through chain 17) |
Every part is ChaCha20-Poly1305 with a random 12-byte nonce prepended and a 16-byte tag, and the associated data is a tag (rand-envelope-receiver, rand-envelope-sender, rand-envelope-body) followed by the note’s commitment, so a key cannot open an envelope it did not seal and an opened note is bound to the leaf it sits under. That is 1,860 bytes of ciphertext; bincode’s length prefixes make it 1,892 on the wire. Chain 20’s genesis sets envelope_bytes: 1860, and its ledger refuses a note envelope of any other length (envelope is 1348 bytes, this chain requires exactly 1860); it checks nothing else about it. Through chain 17 the envelope had no memo field and was 1,348 bytes (1,380 on the wire), and the ledger only capped it at 2,048 bytes, which is still the rule on a chain whose genesis has no envelope_bytes.
The memo field is len (u16 LE) ‖ UTF-8 text ‖ zero padding, 512 bytes, so at most 510 bytes of text. Every note envelope since chain 18 has it, whether or not there is a memo: a change output, a dummy output and a payment without a memo are padded the same way, and nothing on chain distinguishes a memo, its length or its absence. The memo is not in the note, the commitment or any proof. It is readable by exactly whoever can already read the note: the owner, the sender through ovk, or anyone handed that output’s TxKey. A malformed memo field (a bad length, invalid UTF-8, non-zero padding) opens as no memo and never costs the owner the note. A memo is anyone’s text, since anyone can pay a dust note to a public address: show it as untrusted, with control and Unicode format characters replaced, as the rand wallet does. rand send --memo seals one into the payment output; rand notes --memo and rand history --memo show it.
The tree, anchors and windows
Commitments are appended to one depth-32 Poseidon2 tree, oldest first, and never removed. Leaf i is the i-th note the chain ever created; genesis deposit notes are the first leaves. A bundle appends four leaves and four nullifiers whatever it moves, dummies included. The empty leaf is all zeros and a node is H(NODE, left, right) (domain tag 7). A node keeps only the frontier, so consensus state never holds the leaves; rand_getWitness rebuilds the full tree from stored leaves on demand, which is why it is the most expensive read a node serves.
A bundle proves membership of its inputs against an anchor, a tree root. Two rules keep an anchor honest:
| constant | value | rule |
|---|---|---|
ANCHOR_WINDOW | 256 blocks; 1,024 on chains 20 and 21 | the anchor must be one of the last window block-end roots; a root the tree passes through mid-block is never an anchor |
TIME_WINDOW | 256 blocks; 1,024 on chains 20 and 21 | the bundle’s time must be within [height − window, height] |
Both windows are 256 blocks unless the genesis sets proof_window_blocks (v0.6.8, 256 to 4,096), which replaces the two together. Chains 20 and 21 set 1,024; chains 14 to 19 had no field.
The windows exist because proving takes real time: a tier-14 bundle proof measures about 100 seconds on a laptop (and since chain 17 the wallet makes a second, tier-10 auth proof, about 7 seconds). 256 blocks was about five minutes at chain 18’s 1.17 s blocks, and a delegated proof on a slow prover was refused at 294 s; chain 20’s 1,024 blocks are about 23 minutes at the 1.34 s the explorer reported there. A wallet that loses the race is told anchor is not one of the last 1024 roots and reproves. rand_getAnchor with no parameter returns the head’s block-end root, the only anchor a prover should build against.
The bundle
Every transaction that moves value carries exactly one fixed 4-in-4-out hidden-asset bundle (since chain 14; it was 2-in-2-out through chain 13), proved by the hidden-asset zkVM guest whose digest hc_bundle is pinned in the genesis file (60af094acfe65d85fdb18fb3d06cf9085dcf28c96e59e87f1ee527226e6e3fce, bundle guest v3, on chains 17 to 20; it was 83d3a3704a1fcdb9bae7136c0a947ffa34bd53f055388395ed705fe8cacd0ef8, guest v1, on chains 14 and 15):
Bundle { anchor, nullifiers[4], commitments[4], fee, burn_a, burn_r, burn_asset, time, envelopes[4], proof,
auth_commit, auth_proof } // the last two since chain 17
Slots 0–1, in and out, carry a private asset A; slots 2–3 always carry RAND. On a RAND-only payment slots 0–1 are dummies and the value and the fee move through slots 2–3. On a token transfer the token moves through slots 0–1 and the RAND fee is paid from slots 2–3 of the same proof. A is a witness word of the proof and is in no public field, so a transfer of any RPL token is Action::None with burn_a = burn_r = burn_asset = 0, field for field the shape of a RAND payment.
The proof states that every real input is a leaf under anchor and carries its slot’s asset, that the four nullifiers are the ones published, that each group balances (in0 + in1 = out0 + out1 + burn_a in the private asset, in2 + in3 = out2 + out3 + fee + burn_r in RAND), and that the spender holds the keys — without revealing which leaves, which amounts, which asset, or who. The three burn fields are the bundle’s only public statement about value leaving the pool: burn_a and burn_asset are non-zero only on a TokenBurn or a BridgeBurn, where they equal the action’s amount and asset; burn_r is RAND burned, non-zero only on a Bond (the stake) and a RegisterAggregator (the bond). The fee is always RAND.
Since chain 17 the authority to spend is split over two proofs (split authorisation). The bundle guest v3 takes the viewing key nk and a salt where the earlier guests took the spend key: it derives pk_self = H(PK, nk), requires every input to be owned by it, and folds c = H(AUTH, nk ‖ salt) into the digest it publishes. A second guest, the auth guest (genesis hc_auth, 1e4e347f44cf86750b30a9a4bdf9ec9256efe353d4ff8017451eca7d195639c1), takes the spend key sk and the same salt, derives nk = H(NK, sk) and publishes the same c. The bundle carries c as auth_commit and the auth guest’s tier-10 proof as auth_proof, and the ledger requires both proofs to verify against the same transaction binding and the two values of c to agree. A prover holding nk and the salt can therefore make the bundle proof but not the auth proof, which is what delegated proving rests on. The salt is 256 fresh random bits per bundle, so auth_commit is public and links nothing. A wallet that proves for itself makes both proofs too, so a self-proved and a delegated transaction look the same on chain.
The shape is fixed, so slots are often dummies: a dummy input is a zero-value note owned by the spender (its nullifier is still published), a dummy output is a zero-amount note, and the change output is present even when the change is zero. A transaction that looked different when there was no change would leak that there was none. A consequence for wallets: each of a bundle’s two groups spends at most two notes. A RAND payment, a bond, a deploy or a call spends at most two RAND notes; a token transfer or burn spends at most two notes of the token and at most two RAND notes for the fee. Value spread over three or more notes in one group has to be consolidated first, by a send to one’s own address.
Since chain 14 the proof is also bound to its transaction: it is made over and verified against a hash of the whole transaction with the proofs blanked, so it cannot be copied onto another action, other envelopes or another chain; since chain 20 the hash also covers the genesis hash, so not onto a chain that shares its chain id either (Transactions).
What is public and what is hidden
| public on chain | hidden | |
|---|---|---|
| a transfer, of RAND or of any RPL token | anchor, all four nullifiers, all four commitments, fee, burn_a = burn_r = burn_asset = 0, time, four envelope ciphertexts, the proof, auth_commit and the auth proof | who sent it, who is paid, the amount, the memo, which asset moved, the change, which leaves were spent, which slots were dummies |
| a token burn or a bridge burn | everything a transfer publishes, with burn_asset and burn_a equal to the action’s asset and amount (and, for a bridge burn, the relayer fee, the backing token and the destination) | which notes were burned, and who burned them |
| a faucet mint | the commitment, the envelope, the amount, the minting validator, and since chain 14 the note’s opening (pk, time, r), which admission recomputes the commitment from | every later spend of the note |
| a genesis deposit | { cm, envelope, amount, opening: { pk, time, r } } in the genesis file, with asset in the opening for a note of a listed token (chain 21’s genesis carries three zUSD notes, chain 20’s carried two): the owner’s pk, the amount and the asset are public by design | when and into what it is later spent |
| a withdraw, a bridge deposit, a token mint or a registration’s initial mint | the amount, the recipient, the note’s time and blinding r (the chain computes the commitment itself) | every later spend of the note |
| an invoke’s payout or program mint (since chain 20), a multisig account’s payment or a perp payout (chain 21) | the amount, the asset, the recipient and r; the note’s time is the bundle’s | every later spend of the note |
| a bridge fee note (since chain 20) | the amount, the asset and time, a derived blinding, the genesis treasury as owner, and no envelope | every later spend of the note |
Value enters the pool with a public amount — a faucet mint, a genesis deposit, a validator’s withdraw, a bridge attestation and its fee note, a token mint, a program vault’s payout — and leaves it as a public fee or burn. Everything in between is a note.
The table is what chain 21’s proofs hide, as chain 20’s did, and it was not always true. On chains 14 and 15 the bundle proof itself disclosed more (the 2026-09-27 zkVM review, INT-2 / GV-1): each table’s LogUp running total was published unblinded, and the v1 guest skipped the Merkle and asset checks for a dummy input and branched on every Merkle path bit, so anyone could read off a bundle proof which of the four input slots were real, and so whether it moved a token or only RAND, and the number of 1 bits in each spent leaf index. Two changes closed it. Chain 16 moved to constraint set 7, which blinds every LogUp terminal and floors every declared table at 2^7 rows, and to the branch-free bundle guest v2, in which every slot, dummy or real, runs the full membership and asset check through the same instructions. Guest v3, which chains 17 to 20 run, is v2’s relation with nk and the salt in the spend key’s place, and is branch-free too. Bundles committed on chains 14 and 15 keep what they leaked. What a proof still shows on chain 20 is its header: for a bundle that is one fixed shape (tier 14, a declared gas limit of 20,479 on every bundle), and for a Call or an Invoke it is the tier, the declared gas limit and whether and roughly how often the call used the keccak or sha256 precompile (Proofs).
Notes without an envelope
Most notes are found by opening their envelope. Some are computed by the chain from public fields, and for those the envelope is either absent or not to be trusted.
- Bridge fee notes (since chain 20). Under
bridge.feesthe chain appends a zUSD note to the genesis treasury on every deposit and every burn (RPL tokens). It has no envelope: storage indexes it with an empty one, sealed to nobody, andrand_getCompactBlocksandrand_getCommitmentsshow it that way, a deposit’s after the deposit note and a burn’s after the bundle’s four. Every word of the note is public: the amount from the transaction and the genesis rate, the asset andtimefrom the transaction,from = 0, the blindingblake3("rand-bridge-fee-r-1" ‖ mu)for a deposit orblake3("rand-bridge-burn-fee-r-1" ‖ txid)for a burn, and the owner the genesisrecipient. - Notes whose envelope binds nothing. A bridge deposit, a token mint, a registration’s initial mint and, since chain 20, an invoke’s payouts and program mints (on chain 21 a multisig account’s payments and a perp payout too) carry an envelope sealed by whoever submitted the transaction, but the chain computes the note from the action’s public fields and never checks the envelope against it. A careless or hostile submitter can seal garbage, so a recipient must not depend on it.
The rand wallet therefore rebuilds these notes itself (wallet::rebuilt_notes_with), in a public pass beside the trial decryption: for every such transaction naming its own pk, and for the fee notes when its address is the treasury, it computes the note and records it at the leaf whose commitment it hashes to. A rebuilt note is only a candidate: a node that lies about a transaction, the fee rate or a hash makes a note go unfound, never credits one. Since v0.6.8 the headers rand_getBlocks returns carry the deposit, mint and registration transactions in public_notes, and on a chain with bridge fees the burns too, proofs stripped, under their real id, so the pass reads header pages only. Invokes are not in public_notes in v0.6.8, so against a v0.6.8 node the wallet finds an invoke’s payout only through its envelope, which the invoker seals and nothing checks. Fullnode main carries invokes there too, proofs stripped, since d0bb73fb; it is a node-only change, not yet rolled onto chain 20’s fleet, and the wallet needs no change for it.
Scanning: how a wallet finds its notes
A node answers “here is the whole tree”, never “here is your balance”. A wallet:
- pages the leaves with
rand_getCommitments(from_index, limit)(at most 1,000 per call) — or, since fullnode v0.8.0, their scan heads withrand_getScanLeaves(from_index, limit): the same rows with the envelope cut to its first 1,208 bytes (kem_ct ‖ to_receiver ‖ to_sender), which is all a trial decryption needs, so the wallet reads a full envelope only for the leaves its keys open, 2.26× fewer bytes a leaf on chain 20’s leaves, and falls back to full pages on a node that answers-32601or the per-block streamrand_getCompactBlocks(from_height, to_height)(at most 128 blocks per call); every bundle-carrying transaction lists four notes and four nullifiers there, and no reader can tell a dummy slot from a real one; - trial-decrypts every envelope: as receiver through its ML-KEM decapsulation key, as sender through
ovk; keeps the notes whosepkis its own; and rebuilds the notes with a public amount from their transactions (above); - computes the nullifier of each kept note and marks the ones that appear in
rand_getNullifiers(from_height, limit); - sums the rest.
rand_getTreeInfo says how far there is to scan: { "next_index", "root", "nullifiers" }. For a spend, the wallet needs the 32-level Merkle path of each real input. The rand wallet keeps its own copy of the commitment tree, built during the scan from the same rand_getCommitments pages, and computes every path itself, so the only tree question a send asks the node is rand_getAnchor, which names no leaf. rand_getWitness(index) stays on the node for other clients. It tells the node which leaf the caller is about to spend, which was the pool’s largest leak before the local tree: a client that has to use it should run its own node, or ask for witnesses it does not need alongside the ones it does.
rand sync # scanned 41 leaves and 37 blocks; 1 notes, 1 unspent
rand notes # index, asset, amount, height, spent, pending, memo
rand history # notes this wallet created for others, opened through ovk
A note is pending between rand send --no-wait and the next sync that sees either its nullifier or blocks past time + window (1,024 on chains 20 and 21, read from rand_getLimits.proof_window_blocks; 256 without it), after which the bundle can never be admitted and the note is spendable again.