This page is a log as well as a manual: the roll notes and "the next cut" sections below are dated
and describe the chain they were written for (14 to 17). What runs now, from
deploy/genesis-chain18.json and AGENTS.md's "Chain 18" and "v0.6.7" entries:
| chain | id 18, genesis a7cb020cc99a33c83fc38cfa0ec1db357f67fbf8b6dab13ab1d9812280b4da76, live since 2026-09-29 04:56 UTC |
| build | v0.6.7 86941a1 (cut on v0.6.7-rc1 0017de7; v0.6.7 rolled node by node) |
| validators | 26 at genesis, 1 000 RAND each; a quorum is strictly more than two thirds of the stake, so 18 of 26, and the chain commits with up to 8 down |
| history | 24 validators run --prune-history 24h; two keep every block: obs1 (ARCHIVE in deploy/nodes.env) and rand-archive-2 (ARCHIVE2) |
| caps | max_proof_bytes 4 194 304 (4 MiB), max_block_bytes 20 971 520 (20 MiB), max_program_words 65 535, max_call_envelope_bytes 65 536 |
| guests | hc_bundle = bundle guest v3, hc_auth = the auth guest (split authorisation, since chain 17); hardening_v6; consensus_domain 1 |
| gas | gas_price 100, byte_price 800, bundle_gas_limit 20 479, metering: "circuit", dynamic {target_block_bytes 10 485 760, target_block_gas 262 144, adjust_bps 1 250, floors 100 / 800} |
| note envelopes | envelope_bytes: 1860 — every note envelope is exactly 1 860 bytes and carries the encrypted memo field |
| faucet | on, for an allow-list: 16 faucet_recipients, 18 faucet_minters, 10 000 RAND per 1 000-block epoch |
| bridge | guardian set 1, rules v2: 100 000 zUSD per backing and 4 000 zUSD across all backings per rolling 24 h |
| aggregation | off: no genesis carries the section and the node refuses one |
All 26 validator keys are one operator's ("Key separation", below). The release rule and how far it has been followed are under "Rolling out a new commit"; the rules for the next cut are under "Cut policy".
Since this table was written: chain 19 (v0.6.7, genesis a3defc93…228a, 2026-10-01,
deploy/cut-chain19-genesis.sh) and chain 20 (v0.6.8, genesis 6210cf07…5135, live since
2026-10-01 06:17 UTC, deploy/cut-chain20-genesis.sh, deploy/cut-records/chain20.record; now on
v0.7.1) — chain 20 is the chain that runs. Chain 21 is prepared, not cut:
deploy/cut-chain21-genesis.sh carries chain 20's shape and value plus three new sections —
fees (burn_base, burn_floor), perps (RPL-3: BTC-PERP on zUSD) and multisig (the
foundation treasury, 2 of 3) — and private multisig's hc_auth_multisig field with 6 MiB proofs
and 19 MiB blocks ("The next cut: the fees section (chain 21)", below), and needs a build at
6f1cd272 or later with feat/private-multisig (712c6336 or later) on every node (signers read
the genesis hash from the node or take --genesis-hash, so they need it only to know fee_rules,
perps, multisig and hc_auth_multisig).
- A chain is defined by its genesis file. Every node needs the identical file; validators are the keys listed in it. Changing the validator set means a new genesis and a fresh chain.
- Set
genesis.timestamp_msclose to the actual launch time, never ahead of it. Too far in the past and block time visibly lags wall clock until the timestamp step bound (60 s per block on a bridged chain,docs/bridge.md§16) lets it catch up. More than 15 seconds in the future and block 1 does not commit at all: every vote, the leader's own included, is withheld until wall time reaches the genesis timestamp (MAX_CLOCK_DRIFT_MS). Check the cut script's timestamp against the clock on the machine that will actually launch the fleet, not the one that cut the file. - More than 2/3 of stake must be online to commit. With equal stakes: 2 validators tolerate none down, 4 tolerate one, 7 tolerate two, 26 tolerate eight (chain 18's quorum is 18).
- Nodes behind NAT dial out to nodes with public addresses (
--bootstrap). On one LAN, mDNS finds peers without configuration. Open TCP 30303 inbound on public nodes. - Bind RPC to
127.0.0.1unless it is firewalled; it accepts transactions from anyone who can reach it. - The swarm caps what it will hold (deep scan 2026-09-24): at most 256 established inbound
connections, 64 inbound handshakes in flight and 2 connections per remote peer
(
network::WireLimits); the next inbound connection is refused at the handshake and the dialer sees aConnectionDenied. A node's own dials — its bootstraps and redials — are never counted against its own caps, so a validator always reaches the peers it dials, and 26 validators plus every explorer and observer sit far under 256; the cap is a bound on what one host can be made to hold, since a fresh libp2p identity costs nothing to mint. The node's peer map is held to the same bound: a new connection evicts the entries that are not connected before it is recorded, and a gossipedStatusfrom an author this node holds no connection to no longer creates an entry at all. - Observers run the same binary without
--validator; they sync, verify and serve RPC. - History pruning (testnet only).
--prune-history <n>m|<n>h|<n>d, at least1h(24hkeeps the ledger and one day of blocks); the rest is deleted every 16 blocks (docs/superpowers/specs/2026-09-24-history-pruning-design.md). Two nodes keep everything — the archives,ARCHIVEindeploy/nodes.env(obs1 on randbridge-web, data dir on a volume) and, since 2026-09-27,ARCHIVE2(rand-archive-2, fra1, a 250 GiB volume) — and a node that falls more than a day behind must sync from one of them. Mainnet units never pass the flag. A pruned node that fails its startup check does not repair itself: re-sync it from the archive. Rollback: a pruned data directory opened by a build ≤ v0.5.6 with the default--verify-chain quickreplays from genesis, finds block 1 missing and truncates the whole ledger to genesis; run an older build on a pruned data directory only with--verify-chain off, or re-sync from the archive. - The one public RPC endpoint is
https://rpc.randprotocol.org— Cloudflare → the web droplet's nginx → the sale service's filtered, per-IP-metered proxy → an SSH tunnel to obs1 → obs1's127.0.0.1:8545, the archive (since 2026-09-27; before that randscan's/rpcroute on E.deploy/caddy/README.mdhas the chain; F's Caddy is not in it and does not run). Every droplet keeps its node's RPC loopback-only. The proxy's allowlist never forwardsrand_importViewingKey. - A delegated prover is never on the public RPC path. An operator node started with
rand-node run --prover <ADDR>(or a separaterand-prover run) listens on its own address, and holds the sending wallet's viewing key — its whole history — while it proves (never a spend key: that witness is retired, and--prover-accept-spend-keyis refused at startup). Keep that listener on loopback (127.0.0.1:8600isrand-prover run's default; the node's--proverhas none and must be given) unless a TLS-terminating proxy fronts it for a LAN or a phone, and never run it on E, the web droplet or any other host in therpc.randprotocol.orgchain, nor route it through that chain. Both stop gracefully on SIGTERM, systemd's default; setTimeoutStopSec=180so a stop lets the proof in flight (about 100 s) finish before systemd escalates. Runbook and trust model:docs/prover.md.
The repository ships scripts used for the live testnet (deploy/):
| script | runs on | purpose |
|---|---|---|
deploy/push-to-vps.sh <ip> <letter> "<bootstraps>" [validator|observer] |
your machine | rsync the source to /root/fullnode, build, install /usr/local/bin/{rand-node,rand}, init a datadir keyed on the genesis hash, install and start a rand-node systemd service |
deploy/rebuild-vps.sh <ip> |
your machine | rsync new source, incremental build, reinstall binaries, restart the service (data kept) |
deploy/vps-setup.sh |
the server | what push-to-vps.sh executes remotely |
deploy/run-a.sh, deploy/run-b.sh |
laptops behind NAT | run a validator bootstrapping to the public nodes. On A the script is run by launchd (deploy/launchd/org.randprotocol.node-a.plist, installed under ~/Library/LaunchAgents by deploy/install-launchd.sh, which copies run-a.sh — and fleet-watch.sh with nodes.env — to ~/rand-node-a/deploy/ so launchd never runs the shared checkout; re-run it after either script changes): it starts at login and restarts on exit, so a reboot no longer takes A down until someone notices — restart it with launchctl kill TERM gui/$(id -u)/org.randprotocol.node-a, never by running the script in a shell beside it |
The server needs build-essential clang cmake pkg-config libssl-dev and a Rust toolchain; the
cloud-init used for the droplets installs them and opens ports 22 and 30303 with ufw. Set SSH_KEY
to the private key to use (default ~/.ssh/id_ed25519; passphrase-protected keys do not work in
scripts).
Service management on the server:
systemctl status rand-node
journalctl -u rand-node -f
rand status
rand-node verify --datadir /root/data-<letter>-<genesis8> --mode full # stop the service firstOn a chain whose genesis names hc_auth_multisig, rand-node verify loads the pinned msauth
verifier key exactly as run does (below, "The msauth verifier key") and refuses to verify without it.
A node accepts at most 256 established and 64 half-open inbound connections, and a libp2p identity costs nothing to mint, so strangers can hold every slot. Three kinds of peer are reserved — admitted past the established-inbound cap however many strangers hold it, and served from the validators' share of the sync budget (SYNC-3):
- every
--bootstrapaddress's peer id; - every
--reserved-peer <PEER_ID>(repeatable;rand-node address --key <file>prints a key's id — the same id the fleet'snodes.envbootstrap lines end in); - every validator identity learned from a signed peer binding. Each
--validatornode signs "my libp2p identity is …" with its validator key and gossips it onrand/<chain>/peerswhen a peer connects (at most every 10 s) and every minute; a node that knows the key from a validator set records it, reserves the peer and persists the record (peer_bindingsin the meta column, a cache — not fsynced), so a restart or a node-by-node roll comes back with its validators reserved before gossip says anything.
A freshly cut chain has an empty store, so until the first announcements arrive only the first
two kinds are reserved: pass the fleet's validator peer ids with --reserved-peer in the unit
(or keep them in the bootstrap list) for a cut. Reserved peers still count toward the 256, so
they take slots from strangers, never the other way round; each is held to four connections of
its own (two for anyone else). Independently, one source address may hold at most four
half-open connections, and a connection that has not finished its handshake in 5 s is dropped
(libp2p's default was 10 s). The binding topic is new: an older build does not subscribe to it,
so this rolls node by node.
Serving sync batches (audit v6, SYNC-3): a node serves at most 32 batch requests back to back and
8 a second to all strangers together, and the same again to validator peers (the reserved ones)
from a share of their own. Over the node-wide budget — or with every serving slot busy — it
answers Busy, and a new asker takes the request to another peer without backing this one off.
A build older than this one cannot decode Busy: it counts the request as failed, backs the
peer off and halves its next batch, a little more than the empty answer it used to get. That
lasts only while such a node syncs from an upgraded one whose budget is spent, and ends with
the roll.
The pool holds at most eight blocks' worth of transaction bytes (8 × the genesis
max_block_bytes: 160 MiB on a 20 MiB-block chain) beside its 10 000-entry count cap. At the
cap a transaction that pays more above its floor per KiB displaces the cheapest pooled ones;
one that pays less, or the same, is refused mempool full. Governance actions (pause, unpause,
listings, rotations) pass both caps and are never displaced.
rand-node run --strict-gossip switches gossipsub to strict validation: a delivered message must
carry a valid author signature, sequence number and source, so an unsigned message claiming
someone else's authorship is dropped by gossipsub itself (without it the node's own rules
already refuse such a Status, SYNC-1). Off by default. The mode governs only what a node
accepts, and every node signs what it publishes, so — tested, not assumed — a strict node and a
permissive one exchange every honest message in both directions: the flag can roll node by
node, and the older advice here (a strict/permissive mix drops messages; only at an
all-stop/all-start) does not hold for this fleet. Turn it on everywhere once it has run on a
canary.
cargo testlocally, commit, push.- Restart local validators from the new binary (
cargo build --release, pointrun-a.sh'sBINDIRat it, thenlaunchctl kill TERM gui/$(id -u)/org.randprotocol.node-a— launchd restarts A from the script). Release rule (audit v4 PROC-3). The rule: a version tag is made only from a commit whose CI run (.github/workflows/ci.yml) is green and whose fullcargo test --workspace --releasepassed on the release machine,the_genesis_hash_is_pinnedincluded; the tag annotation names the run. A known-failing test is a reason not to tag, never a note to tag over.
The record (audit v6, 2026-09-30): the rule was written and not followed. By the audit's count
CI was red at the tagged commit for 10 of the 12 tags since v0.5.8 — eight died at the checkout of
the pinned circuits commit, v0.6.3 on the write-ahead-log test, v0.6.4 on a guest-provenance test
that lagged chain 17's genesis — no tag annotation names a run, no nightly run has been green, and
v0.6.7 was tagged before its full suite finished, on instruction (AGENTS.md, "v0.6.7"). Until
2026-09-30 this paragraph stated the rule as if it were the practice.
The mechanism (from 2026-09-30). .github/workflows/release.yml runs on a pushed v* tag. It
refuses to build unless the ci workflow's latest run for the tagged commit concluded success,
or the tag's annotation carries a line ci-override: <who> <why> — an exception that is copied
into the release notes, not a default. It then builds the three binaries on a clean runner and
publishes them with SHA256SUMS and a build attestation; the release notes name the CI run. Fleet
binaries come from that release, not from a hand build ("Release trust", below). The workflow has
not yet produced a release: the first tag after 2026-09-30 is its first run.
deploy/rebuild-vps.sh <ip>per server, staggered so that more than 2/3 of stake stays up. A restart costs a node a few seconds; it resumes from its persisted head, verifies the chain, and batch-syncs what it missed. Do not leave nodes on different builds for long: a node still on the previous build cannot answer block fetches for the new one.
Roll note for v0.5.4's lock rule (audit v4 CON-4). A validator's lock is now released only on
signed NotHeld answers from validators holding a quorum of the stake — strictly more than two
thirds since v0.5.5 (audit v5; v0.5.4 released on a third) — (docs/consensus.md, "The lock"), and
a node on an older build never sends one: in a mixed fleet a lock on a block no peer holds simply
holds, and that validator withholds its vote until a newer QC forms without it. Roll all
validators, one at a time waited to rand_getHealth: ok, before relying on the rule; the v0.5.4
database stays forward-compatible (no new column family — META_LOCKED_BLOCK is a CF_META key an
old build never reads), so the rollback from v0.5.4 is the re-pin of the previous binary. Since
v0.5.5 every certified block above the head is persisted too (META_PENDING_BLOCKS, another
CF_META key an old build never reads) and restored at startup, so a whole-fleet restart no longer
leaves every validator's high QC naming a block nobody holds — the 2026-09-24 stall.
Roll note for the not-held freshness fix (scan 2026-09-27, CN-3): one at a time, not
all-stop. A NotHeld now signs rand-not-held-2 ‖ genesis ‖ hash ‖ view and carries the
signer's view as a field appended last; the asker counts it only when its view is above the
locked QC's and within 256 views of its own (docs/consensus.md, "The lock"). The sync wire is
CBOR with named fields, so nothing fails to decode in a mixed fleet (pinned by
network::wire's a_not_held_decodes_across_the_roll_and_counts_on_neither_side): an old node
skips the unknown view field and its rand-not-held-1 check refuses the signature — counted
nothing, next peer asked, exactly as for Block(None), no penalty; a new node reads an old
answer with view 0, which the new tag refuses and every lock is above — counted nothing too.
Votes, proposals, NewViews, blocks and every validity rule are unchanged, so the chain keeps
committing through any mix and there is no fork window — which is why the roll is the usual
one node at a time waited to rand_getHealth: ok rather than v0.5.6's all-stop. The cost is
the v0.5.4 D15 corner only: while the fleet is mixed, a lock on a block nobody holds can be
released only by the new build's words from more than two thirds of the stake (13 of the 18
equal stakes), so keep the roll short and do not leave the fleet mixed. Rollback is the re-pin of
the previous binary (no storage change).
Roll note for v0.5.5's storage layout (audit v5 OPS-4). A committed block's certificate is
stored once: it lives in its child's justify, and CF_QCS keeps only genesis' row and the
head's. The first open on v0.5.5 deletes the row v0.5.4 wrote for every other height (~250 000
rows, ~12 GB on a chain-14 validator) in one batch, sets META_QCS_PRUNED, and compacts the
family; a node killed mid-pass finishes it on its next open. Startup verification reads each
block once (the child is carried into the next height), so it is no slower than before. Rollback
below v0.5.5 needs a resync: a v0.5.4 binary reading a pruned database finds no row for a
non-head height and fails committed_block with Corrupt — it cannot serve sync and its own
startup verify refuses the chain — so the old binary must start from an empty data directory.
No wire, consensus or genesis change: v0.5.5 and v0.5.4 nodes interoperate, and the roll is one
node at a time waited to rand_getHealth: ok as usual. The slope this halves: an idle chain-14
block is ~60 KB of blocks (the 18-signature Dilithium2 QC inside the header) and ~0 of qcs
where it was ~49 KB more — about 3.6 GB/day at the fleet's measured 1.4 s blocks, 1.7 GB/day at
3 s (the D19 numbers; the release notes carry the figure measured after the roll).
Roll note for v0.5.6 (the deep scan, 2026-09-25): all-stop, all-start, like v0.5.5.
Node-only, same chain, rollback = re-pin 0154fe2. One of its rules is a block-validity rule as
well as an admission one: a Call proof above tier 14 (or with an oversized hash table —
docs/confidential.md) is refused by v0.5.6 and accepted by v0.5.5. No such call has ever been
committed on chain 14 (all 340 are tier 10), but the public RPC admits submissions from anyone,
so a one-at-a-time roll has a window in which an old-build leader could commit such a call and
split the fleet (the independent review's one finding). The roll therefore installs the binary
everywhere first, then stops all eighteen and starts all eighteen (deploy/roll-all.sh, ~15 min
of no commits, the v0.5.5 procedure) — no mixed fleet ever runs. A sealed-form side table of the
wrong length is refused instead of crashing the node (no aggregation section on chain 14, so
unreachable). The swarm now refuses inbound connections past 256
established, 64 in handshake and 2 per remote peer ("Topology rules"); a validator's own dials
are never refused. deploy/fleet-watch.sh (every five minutes from the laptop via
deploy/launchd/org.randprotocol.fleet-watch.plist) alerts on a stall, a node behind, disk_low,
mixed builds or an unreachable droplet; update-droplet.sh takes WANT_SHA=<release sha> and
verifies the binary against that rather than the build host's own.
Both binaries are checked (ops review OPS-2). The rand wallet binary is installed beside
rand-node and run as root by the roll scripts, so it is verified exactly like the node:
update-droplet.sh and cutover-droplet-chain14.sh take WANT_SHA_WALLET=<release sha of rand>
beside WANT_SHA, check the build host's copy and the droplet's copy of each before anything is
installed, and refuse WANT_SHA without WANT_SHA_WALLET; roll-all.sh takes the wallet's sha
as its fourth argument (or WANT_SHA_WALLET) and refuses a roll without it. A release's
annotation therefore names two sha256s: rand-node and rand.
Disk guard (audit v4 OPS-3). A node refuses to start under 1 GB free on its data directory's
filesystem (--min-free-disk-mb, default 1024, names the directory and the flag), and
rand_getHealth answers disk_low with the free byte count under 4 GB, re-measured every status
tick with a warning in the log — the 2026-09-24 stall was seven full disks and a health check that
said ok right up to the crash loop. A roll waits on ok, so a droplet near full now stops the
roll at that node instead of at the next one that fills.
The compact-blocks release changes the consensus-topic wire format and is not backward
compatible: an old node cannot decode GossipMessage::CompactProposal or the new
SyncRequest::Transactions / SyncResponse::Transactions variants, and a new leader publishes
only the compact form. A new node still accepts an old leader's full proposal.
What an old node does with a compact proposal. It reports the undecodable message to
gossipsub as Reject (network/mod.rs ~884-891, "this peer's fault"), so it neither votes on it
nor relays it. This node configures no gossipsub peer scoring: the gossipsub construction at
network/mod.rs:539-555 sets none and nothing in network/ mentions with_peer_score, peer
score parameters, thresholds or a graylist. So a Reject only drops the message at that node and
does not forward it; it does not penalise the forwarding peer or prune a mesh. The effect is
that old nodes between new nodes are relay holes for compact proposals, while the new-to-new
mesh is unaffected. Restarting a node onto the new build is the whole cure.
Roll order and why. Observers and archives first: they relay consensus gossip and serve
fetches, and while old they would drop (and Reject) compact proposals; rolling them early costs
nothing, because a new node still accepts full proposals from old leaders. Then every validator
at once, with deploy/roll-all.sh — not staggered as in step 3 above, and not in batches.
A mixed validator set is a stopped chain, not a slow one. A commit needs three consecutive
certified views: chained HotStuff finalises a block only when it, its child and its grandchild are
certified in views v, v + 1, v + 2 (consensus/commit_rule.rs:28-40, and the live path's
consecutive_views check at consensus/hotstuff.rs:51-53, :1564-1573). Leaders rotate by view
over the validator list, validators[view % n] (types/validator.rs:87-93). An old-build
validator cannot decode a compact proposal, so while more than a third of the stake is on the old
build, every view led by a new-build validator gets no quorum and times out. Views led by
old-build validators still certify (a new node accepts a full proposal), but a block commits only
when three consecutive views are all led by old-build validators — with the two builds' leaders
interleaved in the rotation that need never happen. Commits can stop entirely: blocks certify
on the old leaders' views, none commit, and the uncommitted tree grows toward max_tree_blocks
(512, consensus/mod.rs:389). Once more than two thirds of the stake runs the new build, both
builds' leaders' views certify again. There is no genesis field and no chain cut.
Procedure. Observers and archives with deploy/update-droplet.sh (one at a time). For the
validators, deploy/roll-all.sh <rand-node> <rand> <sha256 of rand-node> [<sha256 of rand>]
(with RELEASE_SUMS and RELEASE_SIG, or ALLOW_UNSIGNED=1): it installs the signed binaries
on every host in deploy/nodes.env with no restart, then stops every node together and starts
every node together, node A last, each stop and start run in parallel over ssh. It is an
all-stop all-start, not a sequential roll: the chain commits nothing for the startup verify (its
header comment says about 15 minutes) whatever the fleet size, and no mixed validator set ever
runs. It is the only validator procedure for this release: any staggered or batched restart is
the mixed window above. It has no observers-first mode; run update-droplet.sh on those
beforehand.
Rollback. All together as well, by the same script: deploy/roll-all.sh with the previous
release's rand-node and rand and their shas — the bytes roll-all.sh kept on each host as
/root/rand-node.prev. A partial rollback is the same mixed window: while more than a third of
the stake is on the old build, the new leaders' views time out and commits can stop. Nothing is
written to disk that the old build cannot read, so the rollback needs no resync.
A node on a chain whose genesis names hc_auth_multisig verifies multisig authorisations at tier
18, and the verifier key for that shape costs ~80 s and ~4 GB to build — more than an
s-2vcpu-4gb validator can spare. So the key is built once, on a big host, and shipped:
-
The file.
msauth-vk-production.bin, the vendored machine'sVerifierKeyBlobserialised whole, made byrand-node keygen-msauth-vk --profile production --out FILE(printssha256 <hex> bytes <n>). It ships as a release asset listed in the signedSHA256SUMS. -
The pin. Its SHA-256 is compiled into the build (
MSAUTH_VK_SHA256_PRODUCTION;rand-node keygen-msauth-vk --print-pinned productionprints it,--check FILEprints a file's). At circuits d217252 (constraint set 9) the production blob is 23 754 bytes with SHA-25668facb06c7f321aaf1d4d541acf6b5c0e9d7f17788ed1c9add6c5a8a8f7acd30; the keygen is deterministic (any host regenerates it byte for byte, ~25 s and ~4 GB peak on an M-series laptop). Both blobs are committed, so a release tree carries them:crates/randprotocol-zkvm/keys/msauth-vk-production.binand, for tests,crates/randprotocol-zkvm/keys/msauth-vk-test.bin(SHA-256c82250e7e7f8ca94246dc512091c1dafe402d5c0f6f4503e585d9381319443f5). The vendoredMachine::install_verifier_key_pinnedchecks it on the raw bytes before decoding anything: the blob is a trust root (a crafted preprocessed commitment could let a forged proof verify), so an unpinned file is never decoded. -
The location. Beside the binary:
/usr/local/bin/msauth-vk-production.binon the fleet (msauth-vk-<profile>.binnext to whateverrand-noderuns).--msauth-vk <path>, elseRAND_MSAUTH_VK, overrides it. A developer's binary — one running from this checkout's owntarget/(cargo run,target/release/rand-node) with nothing beside it — falls back to the committedcrates/randprotocol-zkvm/keys/msauth-vk-<profile>.binonly in a debug build or withRAND_MSAUTH_VK_DEV_FALLBACK=1(pin-checked the same). A binary anywhere else, including one a fleet host built in its own checkout and installed to/usr/local/bin, reads the fixed path only, and a missing file there fails the start. -
The startup failure. On a multisig chain a missing or refused file stops the start, naming the path, the file's SHA-256 and the expected one. There is no automatic fallback build; only
--build-msauth-key(development, a ≥ 8 GB host, never set by a deploy script) builds the key.rand_status.msauth_keyreportsloaded,builtorabsent(nohc_auth_multisigon this chain). -
rand-node verifytoo. The offline replay loads the same blob the same way: the fixed path/usr/local/bin/msauth-vk-production.bin(--msauth-vk/RAND_MSAUTH_VKoverride it), a loud refusal naming the path and both digests when the file is absent or not the pinned one, and never a build. -
The genesis field. The chain-21 cut writes
hc_auth_multisigviarand-node genesis --auth-multisig-guest; seedeploy/cut-chain21-genesis.sh(the gate lands withfeat/chain21-msauth). -
The roll.
deploy/roll-all.shtakes the blob as its fifth argument (orMSAUTH_VK;NO_MSAUTH_VK=1for a chain without multisig) and, on each host, checks the copy against the pin of therand-nodebeing installed before installing anything, then installs it beside the binary before any restart. UnderNO_MSAUTH_VK=1a blob already on a host is still checked against the new build's pin, and the host is skipped on a mismatch. Node A on the laptop gets the same file beside its own binary, checked by that binary's own--check/--print-pinned. The release workflow shipsmsauth-vk-production.binbeside the binaries, inSHA256SUMSand the build attestation, after asserting the builtrand-nodepins it. -
The cutover.
deploy/cutover-fleet-chain21.sh(owned by the chain-21 cut session) carries the same copy-and-check, per host, before the first start:# msauth verifier key (private multisig, v0.8.0): beside the binary, SHA-256-checked against the # pin of the build being installed, before the first start scp "$MSAUTH_VK" "root@$host:/root/msauth-vk-production.bin.new" ssh "root@$host" 'chmod 755 /root/rand-node.new \ && got=$(/root/rand-node.new keygen-msauth-vk --check /root/msauth-vk-production.bin.new) \ && want=$(/root/rand-node.new keygen-msauth-vk --print-pinned production) \ && [ "$got" = "$want" ] \ && install -m 0644 /root/msauth-vk-production.bin.new /usr/local/bin/msauth-vk-production.bin \ && rm -f /root/msauth-vk-production.bin.new' \ || { echo "cutover: $host msauth key blob is not the pinned one" >&2; exit 1; }
It compares against the pin of the binary being installed on that host (
rand-node.new), not a pin read on the operator's machine, so a blob and a build from different commits never pair.
Written 2026-09-30 (audit v6, PROC-4 and PROC-5). Until now every fleet binary was built by hand
on one host — which is also the explorer's host and a validator — uploaded with a SHA256SUMS
copied from that build by the person who made it, and installed as root on 26 nodes. That
checksum catches a corrupt download. It does not catch a compromised build host: the build host
was the trust root of the fleet, and on the 20 non-guardian hosts a bad rand-node reads the
validator key beside it — 20 keys, more than the quorum of 18. No commit and no tag is signed,
and main is not a protected branch.
What a release is from the next tag on, and who is trusted for what:
-
The build: CI, from the tag.
.github/workflows/release.ymlruns on a pushedv*tag. Its first job refuses unless theciworkflow's latest push run for the tagged commit concludedsuccess, or the tag's annotation carriesci-override: <who> <why>(copied into the release notes; a lightweight tag cannot carry one). Its second job buildsrand-node,randandrand-proveron a cleanubuntu-24.04runner — fullnode at the tag, zkp-circuits at the tag's ownCIRCUITS_PINbeside it, the compilerrust-toolchain.tomlpins,cargo build --release --locked,RAND_BUILD_SHA= the commit — writesSHA256SUMS, attests build provenance over the three binaries, and creates the GitHub release with the CI run's id in its notes. Check a downloaded binary's provenance withgh attestation verify <binary> -R randprotocol/fullnode. Nogh release createby hand for a fleet binary. The binaries need the glibc of Ubuntu 24.04 or newer. -
The signature: a person, off the build host. The workflow does not sign. The release key holder downloads
SHA256SUMS, checks the attestation, and signs the file on a machine that is not a build host and not CI:ssh-keygen -Y sign -n rand-release -f <key> SHA256SUMS # writes SHA256SUMS.sig gh release upload <tag> SHA256SUMS.sig -R randprotocol/fullnode
The key is held off the build host, preferably hardware-backed (
ed25519-sk); its public half is one line ofdeploy/release-signers, and a change to that file is a reviewed change — whoever is listed there can authorise a binary that runs as root on the fleet. SSH signatures becausessh-keygenis on every host already. -
The check: every roll script, before it installs.
deploy/lib/verify-release.sh:verify_release_sums <SHA256SUMS> <SHA256SUMS.sig>refuses a missing signature, one that does not verify, one made for another namespace, a signer not indeploy/release-signers, and an allowed-signers file with no key in it;verify_binary <file> <SHA256SUMS>refuses a binary the signed file does not list at that sha256.deploy/update-droplet.shanddeploy/roll-all.shtakeRELEASE_SUMSandRELEASE_SIG, verify first, and read the expected sha256s from the signed file. Without them they print that the install is unsigned and refuse, unlessALLOW_UNSIGNED=1says so on purpose.SELFTEST=1 bash deploy/lib/verify-release.shruns the cases on a throwaway key (13 checks).gh release download <tag> -R randprotocol/fullnode -D rel # rand-node, rand, rand-prover, SHA256SUMS, SHA256SUMS.sig RELEASE_SUMS=rel/SHA256SUMS RELEASE_SIG=rel/SHA256SUMS.sig \ deploy/roll-all.sh rel/rand-node rel/rand "$(awk '$2=="rand-node"{print $1}' rel/SHA256SUMS)" "$(awk '$2=="rand"{print $1}' rel/SHA256SUMS)"
What is not in place yet (2026-09-30), stated so nobody reads the above as done:
deploy/release-signerslists no key. Until the operator adds the public key,verify_release_sumsrefuses every release and the scripts install only withALLOW_UNSIGNED=1— which is exactly the old trust model, now said out loud on every run.release.ymlhas not produced a release. Its first run is the first tag after this date. The releases up to v0.6.7 were hand-built and are unsigned.mainis not a protected branch and no review is required. Both are repository settings the operator applies: protectmainand thev*tags, require a pull request with one review and a greencirun before merge. Until they are applied, anyone with write access can changedeploy/release-signersor the workflows without a second person seeing it.- The per-chain cutover scripts (
deploy/cutover-fleet-chain18.shand earlier) still fetch and check a bare sha256; the next cut script sourcesverify-release.shthe same way it sourcescut-policy.sh. - The guardian hosts. A
randbinary installed as root there can read the guardian keys. The audit asks that each guardian operator installs on the hosts they run, with a second person checking the hash; that is a process, not a script, and is not in place. - Reproducible builds — two builders reaching the same hash from the tag — are the step that takes the build host out of the trust root altogether. Not attempted; it needs the clean-clone build first (PROC-2).
- Stop one of four validators: the chain keeps committing, with a timeout on the absent leader's views; the node resumes from disk and catches up in seconds.
- Stop two of four: the chain halts (no quorum) and resumes without a fork when they return.
- Corrupt a node's RocksDB (garbage block, wrong account balance): detected at startup, truncated, resynced from peers, ending byte-identical to the others.
- Random transfers submitted through different nodes: all committed; balances identical on every node; total supply unchanged.
- A dropped peer link is re-established by the redial logic within about 30 s.
Two hardware tasks gate the next milestones. For any future session: this is the checklist;
the runbook with exact per-run commands and estimates is circuits/recursion/docs/03-gpu-and-self-recursion.md
Appendix A (11 rows). The deferred proofs are all written and #[ignore]d with run-alone command
lines in their test files.
- The ≥ 64 GB batch (runbook rows 1–6; one machine, one session, sequential with an RSS
watchdog — the 48 GB laptop jetsams these at ~33 GB): the ERC-20 transfer proof, the SPL token
proof (
research/tests/e2e.rs, both#[ignore]d), the rVM tier-20 exit (tier 21 before the phase-2 row cuts;recursion/tests/exit.rs), the M5.3 N=2/N=3 test-profile aggregates (recursion/tests/aggregate.rs), the production N=1 aggregate. Record per run: wall time, peak RSS, proof size. Then fill chain-9'sgenesis.aggregation.admitted_shapes[0](the production bundle guest's declared heights +hc,aggregate_program_digest(shape, key), the startup key-build wall at 2^21, the warm verify wall) — the zero-digest placeholder refuses to init, so this measurement is what activates chain 9. Per the 2026-09-15 ruling the batch runs after chain-side aggregation lands on main. - The fleet GPU node (runbook rows 8–10): Linux, R580+ driver, CUDA 13, LLVM 21, sm_80+,
80 GB device (H100/A100-80G), ≥ 160 GB host RAM — none exists today. Unlocks the first PTX
build + hardware bring-up (
circuitsPTX_BUILD.md's checklist, param-ABI audit first), then the production N re-measurement (runbook rows 7–8, production N=2/N=3, the ≥ 160 GB host classes), and later the self-verifier's end-to-end (row 11, est. tier 22 / ≥ 128 GB).
Written 2026-09-30 (audit v6, OPS-7). Chains 15, 16, 17 and 18 went live on 2026-09-26 13:05, 2026-09-28 16:39, 2026-09-29 03:03 and 2026-09-29 04:56 UTC: four hard forks in 2.7 days, chain 17 replaced after 1 hour 53 minutes. Each was an all-stop of the fleet from one laptop on one operator's go, with a genesis nobody else rebuilt; only chain 17's record says what it dropped (130 RAND faucet-minted to a wallet the operator does not hold), and wallets, the explorer and the site were not rebuilt for constraint set 8 before chain 18. None of that loses anyone's money while every balance is the operator's to re-issue. Once anyone else holds a balance, a cut that does not say what it carries can lose it. So, from the next cut on, a chain cut needs all six:
- A reason a rolling update cannot meet. A changed validity rule, wire format or verifier
key. A node-only change rolls onto the live chain (
deploy/update-droplet.sh, ordeploy/roll-all.shwhen the fleet must never run mixed). A feature that is merely ready is not a reason; several fork-requiring changes wait and ship in one cut. - A published list of what the cut carries and what it drops, written before the cut. Every balance re-issued at genesis, by holder; every balance not re-issued, with its amount — every non-operator balance in particular — and unwithdrawn validator rewards. "Nothing" is written out, from a scan, not left blank.
- A client rebuild checklist, done before the cut: the wallet CLI (
rand), the clients repository's apps (desktop, web, extension, iOS, Android), randscan, the website's WASM, and the bridge relayer'srand. Each named with the version or commit built against the new chain. A chain no shipped wallet can send on is not live for anyone but the operator. - Two people. One authors the genesis. A second rebuilds it from the tag on another machine, and the two genesis hashes must match before anything is published or stopped. Neither is a session acting on the other's go.
- A rollback plan: what stays on every host (the old chain's data dirs, the old binaries), for how long, and the steps back.
- A chain id never used before. Signatures, the PQ co-signature and the bridge governance messages bind the chain id; ids 14 to 18 have not repeated, and that is what protects them.
The mechanism. deploy/lib/cut-policy.sh is sourced by a cut script and refuses, non-zero and
with the reason on stderr:
| function | refuses when |
|---|---|
require_cut_record <file> |
the record is missing, or has no non-empty reason:, carries:, drops:, clients:, second-operator: or rollback: line (an unfilled <placeholder> counts as empty) |
refuse_reused_chain_id <id> |
any deploy/genesis*.json in the repository already has that chain_id, the id is not a positive integer, or no genesis file can be read |
require_second_rebuild <file> <hash> |
the record's second-hash: line is absent or differs from the author's genesis hash |
require_key_separation_or_testnet <genesis.json> [status] |
deploy/key-separation (or status) says met: no and the genesis does not set "testnet": true; the status file is missing or has no met: line (audit v6, OPS-6: no mainnet genesis by one key holder) |
deploy/cut-record.template is the form; a filled record is committed and published before the
cut. SELFTEST=1 bash deploy/lib/cut-policy.sh exercises all four on temp files (44 checks). The
scripts that cut chains 15 to 18 do not source it — they predate it and are history; the next
cut script must, calling require_cut_record, refuse_reused_chain_id and (on the finished
file) require_key_separation_or_testnet before its first
step and require_second_rebuild before it pushes a genesis. The library checks that a record
exists and is complete. It cannot check that the record is true, that it was published
beforehand, or that the second operator is a second person: the record is the evidence for
those, and they are the operators' to keep.
The audit's target (its option 1 under OPS-6, from the 29 September key-management policy it cites; that policy is not in this repository): at most 8 of the 26 validator keys, and at most 2 of the 8 guardian keys of each set, per person and per hosting account. A holder of nine validator keys can stop a quorum of 18 of 26; a holder of eight cannot. Meeting both caps needs at least four independent operators.
Not met, as of 2026-09-30. All 26 validator keys are one operator's, and so are the guardian
keys (AGENTS.md, "Chain 15": the eight validators added on 2026-09-27 include the six guardian
hosts). One compromise of that operator's control plane is a quorum. Where the validator keys
generated on the laptop for chain 14 are backed up, and whether a live copy remains there, is not
recorded in this repository. This is carried as a dated exception, recorded here so that it is a
decision and not an oversight. Schedule, set 2026-10-01 by the operator: by 2026-11-01, and
before any mainnet genesis, 18 of the 26 validator keys and 6 of the 8 guardian keys of each set
go to three other operators, each on their own hosting account (names recorded here when they take
delivery), so no person or account holds more than 8 validator keys or 2 guardian keys. The
status is deploy/key-separation (met: no, deadline: 2026-11-01) and the mechanism is
require_key_separation_or_testnet in deploy/lib/cut-policy.sh: while met: no, a cut
refuses any genesis that does not set "testnet": true — a mainnet genesis cannot be cut by one
key holder by mistake; a passed deadline is warned about on every cut until a new date is set.
Until it is met, the network is a testnet run by its authors, and the bridge caps stay where they
are. When the keys are handed over: flip met: to yes, record the holders here, and only then
can a genesis without the testnet marker be cut.
Full step-by-step order of operations: docs/superpowers/handoffs/2026-09-20-chain14-cut-runbook.md.
Outcome and the launch record: AGENTS.md's v0.5 entry; the live facts table: deploy/README.md.
This section is the reusable checklist for a hard fork that changes genesis format, wire format and
key custody all at once — worth re-reading before chain 15.
- Merge first, linearly, no merge commits — a rollback story depends on it (user: "so rollback is easier").
- NTP on every validator before the cut, not after. Chain 14 is the first bridged chain with a
forward timestamp bound (B2,
docs/bridge.md§16): a validator whose clock is more than 15 s off silently stops voting. - Fleet disk survey before touching anything. Chain 13 stalled at
ENOSPCon seven droplets because chain-11 and chain-12's retired data dirs were never deleted, and chain 13 itself grew ~16 GB/day — delete every retired chain's data dir (guarded to the unit's own current datadir only) and re-check free space on the 48 GB droplets specifically before rolling the next cut. - Generate fresh keys off-repo, back them up, and never commit them. A public repository that
tracks validator seeds on a chain with a live bridge lets anyone finalize conflicting blocks
(OPS-1).
deploy/gen-chain14-keys.sh→$KEYDIR(default~/.rand-chain14); back the whole directory up somewhere off the laptop before going further — there is no second copy. - Cut the genesis close to the actual launch time, not the evening before: the chain's clock
zero is the genesis
timestamp_ms, and on a bridged chain that also starts the guardian-set grace window and the mint-cap day.rand-node initon the finished, bridge-spliced file is the real hash — not whatrand-node genesisprinted on the unbridged one. - Roll bootstraps first (C, D), then the rest one at a time waited to
rand_getHealth: ok, node A last. Nothing commits until 13 of 18 are up; that is quorum, not a fault. - Redeploy the explorer in the same window, not after — a node's RPC amount encoding is a
breaking change too (every
u64amount became a decimal string in this cut) and an explorer built for the old shape renders nothing on the new chain. Check its migration numbering against what the database has already tried: a version number consumed by a reverted feature (chain 14 hit this — receivers migration 8, reverted, silently skipped the next migration numbered 008) stalls the indexer at whatever height that skip happened. - List a bridged token on Rand before enabling it on the source endpoint, never the other way: a deposit into an endpoint with nothing to credit it against on Rand is refused but safe in custody; the reverse has no symmetric failure.
- A guardian-set rotation is independent of the chain cut and can follow it at any time — Rand's genesis starts at the guardian set already in use and takes the rotation attestation later, so there is no reason to gate the cut on it.
Scripts: deploy/cut-chain17-genesis.sh (the genesis), deploy/cutover-fleet-chain17.sh (the
fleet), deploy/chain17-bridge-steps.md (the bridge session's half). What changes: the genesis
is cut with rand-node genesis --hardening-v6 --bundle-guest v3 --auth-guest — hc_bundle is the
v3 hidden-asset guest (60af094a…, EXPECT_HC_BUNDLE) and the new hc_auth (the auth guest's
digest, hashed after hardening_v6) is pinned by the operator from the v0.6.3 release binary
(EXPECT_HC_AUTH, defaulting to guest_provenance.rs's pin 1e4e347f…39c1 and cross-checked
against what the built binary reports). A v3 transaction carries up to three proofs (bundle,
auth, call), so Genesis::validate requires max_block_bytes ≥ 3·max_proof_bytes + 1 MiB with
hc_auth: the cut halves the proof cap to 4 MiB (MAX_PROOF_BYTES, every admissible proof is
≤ 2 MiB by the zkVM cap; chain 16's 8 MiB would need 25 MiB blocks) and keeps 20 MiB blocks. Every
bundle then carries an auth proof, every txid changes (rand-txid-3), and the wire changes:
a v0.6.3 node refuses chains 14/15/16 at startup and a v0.6.1/v0.6.2 node cannot decode a chain-17
bundle, so the roll is all-stop/all-start onto a new genesis, chain 16's shape (26 validators, the
same keys, faucet_minters the 18 operator keys, the bridge's live set and a re-derived replay
floor). Wallets, the relayer's rand, randscan and the website move to v0.6.3 with it.
The carry-over rule. Chain 17 starts from chain 16's value, but only value whose opening the
operator can re-issue: zUSD as chain 16's per-backing locked with fresh genesis notes to the
holders in ZUSD_CARRY (Σ notes == Σ locked == source custody); RAND as one genesis alloc note per
wallet the operator holds, at its chain-16 balance; the vesting register re-emitted with each
entry's claimed amount subtracted; validator stakes as chain 16's register holds them (asserted,
not assumed). Shielded notes of wallets the operator does not hold cannot be carried — a
genesis note needs an opening — and the launch notes must say so. Two more things are refused
rather than silently dropped, each with an explicit opt-out: a validator with unwithdrawn
rewards on chain 16 (REWARDS_DROPPED_OK=1 — rewards are never carried either way, withdraw
them first if that is not the intent) and a wallet reporting pending notes (PENDING_OK=1, a
note submitted but not yet confirmed is not in balance and would otherwise vanish with no
trace). The vesting carry is an approximation, not the correct one: the right carry is
A·f(t) − C (A = amount, C = claimed, f = the vesting curve), which the genesis format
cannot express without a claimed field; carrying (A − C) on the same schedule instead
front-loads C·(1 − f(t)) — that much unlocks early — so an entry with claimed > 0 is refused
unless VESTING_CLAIMED_OK=1 accepts the approximation. The balances step
(cut-chain17-genesis.sh balances <snapshot dir>, read-only toward the chain, after snapshot,
while chain 16 is up) runs rand sync + balance + notes for every *.key.json under
WALLETS_DIRS (default: chain 14's and chain 15's wallets/ and payout/, plus chain 16's
wallets/ (since chain 16 has no payout/ of its own yet) and $FULLNODE_DIR/wallets — this
repo's own gitignored wallets/, where the chain-16 genesis allocs shielded-1..5 (the chain-15
cut's ALLOC_WALLETS default) actually live) against chain 16 and writes ALLOC_ADDRESSES from
the non-zero balances plus balances.json beside the snapshot; it refuses against a pruned
CHAIN16_RPC (a scan needs an archive). It also decodes chain 16's own genesis alloc notes' pk
and lists any that match no scanned wallet ("uncovered genesis allocs" — UNCOVERED_ALLOCS_OK=1
to drop them knowingly if a wallet's key file genuinely cannot be found). The cut asserts the
alloc list is those wallets one-for-one and Σ alloc ==
Σ scanned (NO_BALANCES=1 is the explicit opt-out); it warns, but does not refuse, when an
operator wallet's Σ zUSD differs from ZUSD_CARRY's (a non-operator holder like Anish's wallet
legitimately differs). A changed FAUCET_RECIPIENTS list needs FAUCET_RECIPIENTS_CHANGED=1,
the same rule chain 16 enforces. EXPECT_HC_AUTH has no compiled-in default — as of this build,
crates/randprotocol-zkvm/tests/guest_provenance.rs pins the bundle guest's v1/v2/v3 digests but
not the auth guest's — so read it from a Linux host after stage: /root/rand-node.c17 genesis --bundle-guest v3 --auth-guest --hardening-v6 … and its printed hc_auth. SELFTEST=1 runs the
assembly twice (once plain, once accepting the VESTING_CLAIMED_OK=1 front-load) and sixteen
refusal cases on fixtures derived from deploy/genesis-chain16.json (eighteen checks total),
with no network and no binary; each refusal is checked against its expected reason phrase, not
just its exit code.
Order and the gate. stage → bridge stop → snapshot → balances → the user's go, typed in
the executing session → stop → cut → push → switch → start → wait → bridge restart →
retire-chain-dirs.sh for chain 16 after a day. stage, snapshot, balances and the cut are
reversible and need no go; stop does. Run every phase alone and read its $? (chain 16's trap: a
piped push … | tail && … start started the fleet after a failed push). push, switch and wait
refuse without LOCAL_NODE (a v0.6.3 rand-node); start refuses unless a successful switch
left /tmp/chain17-switched-<genesis prefix>.
The audit-v4 release (v0.5.4) added consensus rules that are switched on by genesis fields chain 14 does not carry, so that chain 14 runs byte-for-byte as before. The next cut's genesis sets:
"consensus_domain": 1— every vote, new-view and proposal then carries the genesis hash (docs/consensus.md, "Signing domains"), so a validator signature for this chain verifies on no other. Absent or0is chain 14's behaviour;2and up are refused byrand-node init. The field is a top-level genesis key besideepoch_blocks; the cut script splices it in the way it splices thebridgesection, sincerand-node genesiswrites today's shape.
"tokens": { …, "burn_registration_fee": true }— aRegisterToken's orRegisterBridgedToken'sregistration_feeis burned instead of paid to the block's proposer (audit v5 TOK-2,docs/tokens.md§15): the proposer keepsfee − registration_fee, the fee joinsrand_getSupply'sburnedandregistration_fees_burned. Absent orfalseis chain 14's rule and commits nothing;trueis committed to the genesis hash under its own tag and folded into the token root, so it ships with a chain cut, never as a same-chain update. Optional inside thetokenssection the cut script already splices in.
"tokens": { …, "bound_note_value": true }— no mint, initial mint or bridge deposit may create a note worth 2^63 or more, nor push an asset'stotal_supplyto 2^63 (the hidden-asset guest's u63 range check makes such a note unspendable for ever;docs/tokens.md§16). Absent orfalseis chain 14's rule and commits nothing;trueis committed under its own tag and folded into the token root. The admission screen refuses such an amount on every chain regardless.
The v4 re-review found the gated staking section still lacked admission, a weight cap, proof of
possession and a delay for top-ups (docs/staking.md §2). Three optional fields inside the section
close them, each omitted from the file and from the genesis hash when absent; the bond queue that
delays top-ups is on whenever the section is:
"staking": {
"faucet_budget_per_epoch": "100000000000", "bond_activation_epochs": 2,
"max_weight_bps": 3333, "max_stake_entry_per_epoch": "10000000000000", "registration_v2": true
}max_weight_bps(1..=10000) caps every validator's weight at that fraction of its set's total, genesis set included. 3333 keeps any one key below a blocking third on a set of four or more; a set smaller than ⌈10⁴ / bps⌉ is levelled to equal weights.max_stake_entry_per_epoch(decimal string,> 0) is the most stake that may become weight at one boundary; the rest waits in bond order. 10 000 RAND above is ten minimum bonds an epoch.registration_v2: truemakes aBond's registration signrand-register-2over the genesis hash and the validator's address: every validator that registers after the cut usesrand-node register --v2, and a v1 registration is refused.
A testnet cut that keeps its faucet beside the bridge (chain 15) adds the faucet allowlist to the
same section, and sets "faucet": true at the top level:
"staking": {
"faucet_budget_per_epoch": "100000000000", "bond_activation_epochs": 2,
"max_weight_bps": 3333, "max_stake_entry_per_epoch": "10000000000000", "registration_v2": true,
"faucet_recipients": [
"rand1…the output of `rand --key ~/.rand-chain15/wallets/dendi.key.json address`…",
"rand1…anish's address, as he sends it…"
],
"faucet_minters": [
"…the address `rand-node` prints for each operator validator key that may run the faucet…"
]
}faucet_recipients(non-empty, no key twice): aMintpays only these wallets (FaucetRecipientNotAllowedotherwise, cached as permanent). Without itfaucet: truebeside abridgesection is refused (FaucetWithBridge). Each entry is a fullrand1…address — whatrand addressprints — or itspkas 64 hex characters; only thepkis committed, so an address can be pasted as it is.rand_mintto any other address is refused at admission. Mainnet never carries a faucet, allowlisted or not — and since audit v6 (STAKE-2) that is a check, not a sentence: a genesis withfaucet: trueand abridgesection is refused atinit(FaucetWithBridgeNeedsTestnet) unless its top level says"testnet": true(rand-node genesis --testnet), whatever itsstakingsection and allowlist say; chains 14–18, cut before the marker, are grandfathered by chain id ingenesis::FAUCET_BESIDE_BRIDGE_CHAIN_IDS. A mainnet genesis carries neither the faucet nor the marker, andrand_status.testnet/rand_getLimits.testnettell every client which it is.faucet_minters(RESCAN-LEDGER-1; the next cut that keeps a faucet sets it, to the operator's own validator keys): aMintmay be signed only by these keys (MinterNotAllowedotherwise). Without it the ledger accepts any key with a register row — which a permissionlessBondwrites, and which is in the active set two epochs later — and only every node's admission policy (the genesis validators) keeps a bonder from draining the budget. Each entry is a validator's base58 address, its 64 hex characters, or itspublic_keyhex; non-empty, no key twice, committed only when present.
Like the rest of the section: rand-node genesis never writes them, the cut script splices them
in, and rand-node init on the finished file prints the hash that matters. Chain 14 has no
section, and rand-node's chain_14s_genesis_file_still_builds_chain_14 pins its hash
(1cff3b7d…) against all of it; chain_15s_genesis_file_still_builds_chain_15 pins chain 15's
(cc30e085…) against faucet_minters.
A chain cut from another one's bridge keeps the source endpoints, their sequence counters and —
at a carried-over guardian set — their signers, so an old chain's already-minted lock is an
ECDSA-valid attestation on its successor (docs/bridge.md §23). The next bridged cut sets, inside
the bridge section the cut script splices in, beside guardian_set_index and burn_sequence:
"bridge": { …, "min_inbound_sequence": { "2": <n2>, "3": <n3>, "4": <n4>, "5": <n5> } }- Each source chain's floor is one past the last lock the previous chain observed from it:
the highest
sequenceof any committedBridgeAttesttransfer from that emitter chain, plus one — read off the old chain (or the guardians' signed-message stores) after its last mint and before it stops, the way the burn sequence and the residue custody are read. Drain in-flight locks first: a lock the old chain never minted but that sits below a minted one would be stranded under the floor (carry its custody at genesis instead). - A source chain that never locked anything toward the old chain is left out (every lock on it is
new). Keys must be chains in
emitters, values above 0, and a present map non-empty (rand-node initrefuses otherwise). The script should refuse to run with any floor unset, the waycut-chain15-genesis.shrefuses to guess the residue custody. - Absent is chain 15's behaviour and commits nothing; present, it is committed to the genesis
hash under its own tag and wraps the bridge root, so it ships with a cut, never as a same-chain
update.
rand-node'schain_15s_genesis_file_still_builds_chain_15pins chain 15's hash (cc30e085…) against it.
Until then — chain 15 runs without a floor — the rule is the guardians' (bridge.md §23): a
guardian brought up with an empty store sets its source cursors past chain 14's last observed lock
(start_block for EVM/Tron sources, start_sequence for Solana) before it starts, never the
configuration's deployment block.
Spec docs/superpowers/specs/2026-09-26-address-sharing-and-memo-design.md §2.3–§2.4.
"envelope_bytes": 1860— every note-creating envelope (Bundle.envelopes[..],Mint,Withdraw,BridgeAttest,Aggregate,TokenMint,RegisterToken's initial mint, and a genesis alloc note) must be exactly 1860 bytes, which is what lets every one of them carry the fixed 512-byte encrypted memo field. Absent (chains 14 and 15 and every earlier chain) is today's rule byte-for-byte: an envelope at mostMAX_ENVELOPE_BYTES(2048), no uniform size, and a memo refused by the wallet before proving rather than silently dropped. The ledger there still admits a memo-carrying 1860-byte envelope (it is under the 2048 cap) and a new wallet opens its memo, so memos are live on chains 14 and 15 as soon as the new wallets ship: anyone can pay a dust note with any memo to a public address. Every surface shows a memo through one display rule (docs/cli.md, "How a memo is shown").1860is the only valuevalidateaccepts today — a future layout is a new value. Set withrand-node genesis --envelope-bytes 1860; the field is hash-bound last, and a genesis alloc note whose own envelope is not exactly 1860 bytes failsGenesis::build(GenesisError::AllocEnvelopeSize) rather than silently mismatching it.- Ship the apps and the website first, the genesis after. A wallet built before this change
cannot open a 652-byte sealed body (its
Note::from_bytessees 624 bytes and fails); a wallet built after opens both the legacy 112-byte body and the memo-carrying 624-byte one. So the cut order (spec §5) is:randprotocol.organd the wallet apps release the new core first, and only then does a genesis withenvelope_bytes: 1860go live — never the other way, or a deployed wallet meets a form it cannot open at all.
Task 17 (plan 2026-09-26-address-sharing-and-memo) closed out the branch with a cross-repo
verification pass and this checklist. Per the user: address sharing itself ships as v0.5.10
(same-chain — nothing here changes the rules of chains 14 and 15, neither of which sets
envelope_bytes), right after this task and its review. The
envelope_bytes: 1860 memo rule above is a separate, later event: it is not part of v0.5.10 and
does not go live until the v1.0 genesis (~chain 20, rand-node genesis --envelope-bytes 1860).
Everything that ships as v0.5.10 works against a chain without envelope_bytes exactly as it does
today (address sharing, the fingerprint, randpay: links, contacts) — only sealing a memo is
gated on a chain that carries the section. Opening one is not: a third-party sender can already
put a memo in a 1860-byte envelope on chains 14 and 15 (see above), and the new wallets show it.
| repo | commit | command | result |
|---|---|---|---|
| fullnode | the release commit (recorded at the release step) | (not re-run — see below) | 61/63 binaries clean, 1478 passed / 13 failed / 15 ignored; the 13 failures are the documented RECURSION_FIXTURES gap (node::tests/rpc::tests, all panicking at agg_executor.rs:295) and the randprotocol-rvm --test aggregate binary is the documented laptop OOM (SIGKILL) — both pre-existing laptop limits, not this branch |
circuits (research) |
95c9072 |
cargo test --release --test viewing |
19 passed, 0 failed |
| randscan-viewing | b889519 |
cargo test |
8 passed, 0 failed (lib 0 + memo.rs 3 + rpl_disclosure.rs 1 + vectors.rs 4) |
| website | 4b8e276 |
node --test tests/ |
28 total, 27 passed, 1 skipped (LIVE_KEY, needs a live node — expected), 0 failed |
website server/address-wasm |
4b8e276 |
cargo test --release |
9 passed, 0 failed |
clients (ui/web/extension) |
96b9999 |
node --test ui/test web/wallet/test extension/test |
772 passed, 0 failed, 0 skipped — includes web/wallet/test/core.integration.test.mjs run for real (not skipped): the wasm core was already built (core/target/wasm32-unknown-unknown/wasm/wallet_wasm.wasm, from Task 13) |
clients core/ |
96b9999 |
cargo test --release |
78 passed, 0 failed |
clients android/ |
96b9999 |
./gradlew testDebugUnitTest --rerun |
28 tests (Amounts 2, Contacts 7, NoteStore 6, SendLink 13), 0 failed, 0 skipped |
clients ios/ |
96b9999 |
xcodebuild test on iPhone 18 Pro simulator |
31 tests, 0 failures |
fullnode's full suite was not re-run here per the controller's ruling: it ran in full at the end
of Task 9 (log: .superpowers/sdd/2026-09-26-address-sharing-and-memo/memo-suite.log), and the
release step records the commit it ships and the suite run against it. Every other repo's commit
matched the plan's final list exactly; every suite is green (the one LIVE_KEY skip is a
pre-existing, expected skip — it needs a reachable live node).
grep -rn "seal_note\|Envelope::seal\|envelope\|BridgeAttest" ../bridge --include='*.rs' finds no
envelope-sealing code in the bridge repo (/Users/dendisuhubdy/Github/randprotocol/bridge) at all,
and no Cargo dependency on randprotocol-client or randprotocol-core from the daemons crate
(only tools/vectors, an unrelated test-vector helper, depends on randprotocol-core).
daemons/src/submit/process.rs's RandSubmitter::mint reaches Rand by shelling out to the rand
CLI's bridge-mint subcommand (daemons/src/submit/process.rs:5-6: "Rand, through the wallet's
rand bridge-mint, because minting a deposit means sealing a note and proving a fee bundle, which
is the wallet's job") — it writes the attestation (and, for a PQ quorum, the co-signature list) to
a scratch file and runs rand bridge-mint @<file> --to <address> as a child process.
Verdict: nothing to change in the bridge repo, and no blocker. The relayer never builds a
BridgeAttest envelope itself; sealing is entirely the rand binary's job, and Task 7 already
made every one of the wallet's sealing sites (including the bridge-mint deposit path in
randprotocol-client/src/main.rs, which seals through wallet::deposit_note_for) fetch rpc.envelope_format() and seal through
seal_note_as. The only operational requirement is that the rand binary the relayer's host
invokes be rebuilt from a fullnode commit at or after Task 7 before the launch genesis is cut —
an ordinary binary update, not a bridge-repo code change.
- Apps and website released with the new core.
randprotocol.org(worktree/tmp/site-memo,4b8e276) and every wallet app (clients,/tmp/clients-memo,96b9999: web wallet, desktop, browser extensions, iOS, Android) ship the code that reads both the legacy 112-byte note body and the 624-byte memo-carrying one, and that speaksrandpay:links, fingerprints and contacts — all of which work unchanged against today's chains (noenvelope_bytes). This is what makes the later genesis cut safe: every deployed client can already open the longer form before any chain produces one. - randscan deployed with Task 10.
randscan-viewing(/tmp/randscan-memo,b889519) opens both note-body layouts and reports"memo": string | nullfromopen_note; deploy it to the explorer (E) alongside or before the genesis cut, so/account's viewing-key disclosure and the sale service's balance reads keep working once a memo chain exists. - The launch genesis cut with
rand-node genesis --envelope-bytes 1860. Per the spec and the "next cut" section above, this is a future chain (~chain 20, v1.0) — not part of v0.5.10. When it happens: cut with the flag, confirmGenesis::buildaccepts every alloc note at exactly 1860 bytes (GenesisError::AllocEnvelopeSizeon a mismatch), and roll the fleet as any other chain cut (docs/deploy.md's topology rules; validators first via the bootstraps, then the rest, node A last). - After the cut, verify:
rand_getLimits.envelope_bytes == 1860on every node (validators and observers alike — a stale or misbuilt node would still answernullor an older value).- A faucet mint's envelope is exactly 1860 bytes (
rand_mint, then read the committed transaction's envelope length back — the same checkunder_envelope_bytes_every_alloc_envelope _is_exactly_that_longandon_a_memo_chain_every_output_is_1860_bytes_and_the_payee_reads_the _memomake in the test suite, now against the live chain). - A payment with a memo round-trips end to end on the live chain: the payee's scan and the
sender's
historyboth show it, andrand_checkTransactionwith the output's tx key discloses it — the same shape as the wallet-flow test (Task 9), now manual and against mainnet.
- iOS: pre-filled Send not observed on the simulator. Task 15's simulator smoke got the OS
"Open in Rand Wallet?" prompt (confirming the
randpayscheme is registered) but nothing on that machine could tap through the unlock prompt to see Send actually pre-filled — Simulator.app was not at the expected Xcode path and neithercliclicknoridbwas available. Logic is covered bySendLinkTests/LinkHandoffTests(unit tests), not by an observed run. - Android: camera scan of a real QR, and the review/prove screens, not exercised on device. Task 16's emulator has a synthetic virtual camera (no way to show it an actual QR — scan and paste share the same resolution code path, which was exercised), and the emulator wallet held 0 RAND with the public faucet returning HTTP 429, so the Review screen, its confirmation line and a real proof were never driven end to end on device — only by unit tests.
- macOS: OS-level
randpay:routing needs a built.app. Task 14's desktop deep-link wiring (tauri-plugin-deep-link) is verified against the plugin's own vendored source and by unit tests only; the scheme is read from the Info.plist a realcargo tauri buildgenerates, so a manual pass (cargo tauri build, thenopen "randpay:rand1...?amount=1"against the installed app) is still owed at release time. - Website: the saved-account share block awaits the account save flow. Task 11's
[data-acct- share]/[data-acct-address]wiring on/addressactivates automatically (via aMutationObserver) once a sign-in/save flow exists, butrandprotocol.orghas no such flow yet in this worktree (nosrc/scripts/account.jsor equivalent) — this predates address sharing and is out of this branch's scope, so it is untested against a real save, not broken.
Nothing in this checklist authorizes acting on it. Every git push of every branch above, the
website deploy, every app-store upload, and the genesis cut itself each wait for the user's
explicit go, typed in the session that will do it — per the standing rule that a relayed go is not
authorization for a fleet-wide or public-facing action (see "Repo workflow traps" and the memory
note on fleet gos).
The 2026-09-27 zkVM/ISA review's R4: every stricter validity rule on a live path (its class H) ships behind one top-level genesis field, so one cut turns them all on together. The next cut sets:
"hardening_v6": trueAbsent or false commits nothing and is chain 15's rules; true is committed to the genesis hash
under its own tag. Until a genesis carries it, each rule below is every node's pool policy (a
node on this build never pools or forwards what the rule refuses), and a block from an older
proposer that carries such a transaction still applies. chain_15s_genesis_file_still_builds_chain_15
pins cc30e085… against the field. The rules, one subsection each:
A Deploy whose padded program table — max(len + 1, 16) rounded up to a power of two rows,
and never fewer than 128 (the table a hardened call declares, 2^MIN_PRIVATE_TABLE_LOG_HEIGHT;
PCW-FLOOR, the v0.6 rescan: measured unfloored, the window admitted fib at 0xffffffc0, whose
floored proof is refused), four bytes a row — runs past the u32 pc wrap can never be proven (the circuit does PC arithmetic
in the field, the emulator wraps), yet ZH4's check_program bounds only base_pc + 4·len: fib's
15 words at 0xffffffc4 deploy and are uncallable for ever. (It first shipped on feat/v0.6 as
its own program_pc_window flag; no genesis ever carried that, and it is folded in here.)
- Under
hardening_v6,base_pc + 4 · rows > 2^32isBadProgramat admission and at apply (program::pc_window_fits). - Every node already refuses such a deploy at its pool, on every chain (node policy, cached as
a byte verdict like the note-value screen); a block from an older proposer that carries one still
applies until the flag is set. Nothing live is affected: every chain-15 program sits at
base_pc0. - The verifier- and prover-side fixes (bound the table in the circuit, refuse to prove) are the zkVM's, upstream.
Every proof pays one Poseidon2 permutation per four program words before it executes anything,
plus one for the empty input's salt row and one for the (empty) public segment's header. A call is
capped at tier 14 (MAX_CALL_TIER), whose Poseidon2 table holds 2^(14-3) = 2 048 permutations,
so a program has at most 2 046 digest rows: 8 184 words, fewer beside a public input
(max(1, ⌈n/4⌉) slots for n public words). Chain 15's genesis admits 65 535-word programs, so a
larger one deploys, is charged deploy_fee, and can never be called — the shipped EVM interpreter
(18 009 words) and sBPF interpreter (8 317) among them.
- Under
hardening_v6such a deploy isProgramUncallableat admission and at apply (ConfidentialExecutor::max_callable_program_words, the prover's own terms —randprotocol_zkvm::executor::max_callable_program_words, pinned one word either side againstbuild_traces_salted). - Every node already refuses it at its pool (
admission::deploy_uncallable), as a non-permanent Ignore: the bound follows the build's call tier cap, which a later build may raise.
A call proof is not bound to its transaction today, so a copy of someone's call proof attached
under another fee bundle yields a second receipt. Under hardening_v6 every call must carry
Transaction::call_binding — the transaction with its bundle proof and call proof blanked, under
rand-call-bind-1, which covers the fee bundle's nullifiers — in its public segment
(ConfidentialExecutor::verify_call_hardened): the whole segment for a program deployed without a
public input, and after the program's deploy-time public words for one deployed with them
(program::hardened_call_segment, public ‖ call_binding; issue #55). The same proof
under another fee bundle is refused, and the nullifiers spend once, so one proof yields one receipt.
- Flag-only, no pool policy: every wallet in the field proves the empty segment, so screening
for the binding at the pool would refuse every call on chain 15. A wallet learns the flag from
rand_getLimits.hardening_v6and then proves the call inside the submission (wallet::submit_bound_call): notes chosen and the fee fixed from the call's tier first (executor::call_tier), the input envelope sealed, the call proved over the call binding, the bundle proved last. - Programs with a public input (issue #55, v0.6.1): the ledger now keeps each program's
deploy-time public words (
Ledger::program_public, outside the state root — the program id already binds them — restored from the node'sprogram_publiccolumn at open and checked against the record's digest there), so such a call is bound too. The guest reads its public words at the same indices; onlyH_PUBmoves. Before, such calls kept the recorded digest and stayed unbound. - Under the flag the CPU-1 bound is taken with the segment a call carries,
public.len() + 8words: 8 180 for a program without a public input.
The program table carries each instruction's fetch count — a call's control flow — and a committed table is hiding only while it has more random rows than the proof opens (80 FRI queries plus two out-of-domain points). The prover already floors the input, keccak and sha256 tables at 2^7 rows (COV-2 / INT-6), but the chain pins a call's program-table height to the deployed record's, so every program under 64 words proves at 16–64 rows and publishes its fetch counts: all 105 programs on chain 15 are 43 words.
- Under
hardening_v6a call declaresmax(record height, 7)(executor:: hardened_program_log_height): the wallet's hardened prover emits it (prove_call_hardened, a taller table is padding the AIR allows, the samehc) andverify_call_hardenedpins exactly it;warm_hardenedbuilds those keys. Without the flag nothing changes, and the old rule refuses a floored proof. No pool policy: every wallet in the field declares the record's height. - Calls already committed on chain 15 stay exposed; only a cut stops new ones leaking.
A proof header or transcript field the verifier accepts at more than one value lets whoever relays
a transaction re-encode its proof into a second transaction id (Transaction::hash takes a proof
by digest) — the sender's wallet then reports its own payment as not committed — and lets a bundle
take a shape no aggregate can cover. randprotocol_zkvm::executor::non_canonical pins each such
field to the honest prover's value, for bundle and call proofs alike:
- The memory table's height (INT-5 / HB-2): exactly
t + 2for a proof without a keccak or sha256 table — 16 for every bundle. A bundle declaring 17 verified. With a hash table the honest height depends on the run's access count, not on the header, so it is capped rather than pinned (issue #56): at mostexecutor::hash_bearing_mem_log_height_ceilingof the declared tier and hash-table heights, which leaves one or two encodings where the verifier's range left up to a dozen. A pin needs the prover to declare the ceiling itself — a circuits change, next constraint set (#52). - The FRI folding schedule (VERIFIER-2 / V-VERIFIER-1): the per-round
log_aritythe verifier accepts at any value that folds onto every input height; pinned to the prover's greedy schedule, re-derived from the declared shape (executor::honest_fri_arities, checked against calls at tiers 10/12/14 and a bundle). - The random-codeword openings (V-VERIFIER-1): four random values at every opened point of a randomised round, none in the preprocessed round — a count the hiding PCS's verifier does not check.
- The commit-phase proof-of-work words (VERIFIER-1): zero. At 0 grinding bits the verifier never reads them, so a relayer could rewrite one and commit a second encoding of someone's bundle under another transaction id.
Under hardening_v6 a transaction carrying such a proof is NonCanonicalProof at admission and at
apply (ConfidentialExecutor::non_canonical_proof, before either proof is verified). Every node
already refuses it at its pool (admission::non_canonical_proofs), as a non-permanent Ignore.
Delegated proving Phase 2 (docs/prover.md §8, docs/shielded.md §2) is genesis-gated and a hard
fork: rand-node genesis --bundle-guest v3 --auth-guest pins hc_bundle
60af094acfe65d85fdb18fb3d06cf9085dcf28c96e59e87f1ee527226e6e3fce and hc_auth
1e4e347f44cf86750b30a9a4bdf9ec9256efe353d4ff8017451eca7d195639c1 (the pins in
crates/randprotocol-zkvm/tests/guest_provenance.rs; the build that cuts must print the same). The
two flags come as a pair, both ways. The v0.6.3 build refuses chains 14–16 at run and verify,
so it rolls only onto a new genesis, all-stop/all-start; wallets, the prover and the bridge
relayer's rand move with it. The cut procedure is "The chain-17 cut" (it arrives with the
chain-17 scripts).
Spec docs/superpowers/specs/2026-09-28-gas-model-design.md §3.2–§4.3, §7.1. Built on
feat/gas-chain18, not yet cut — see the AGENTS.md v0.6.6 entry for the review traps and
gate counts. Chain 18 follows chain 17 (live since 2026-09-29, v0.6.3): it carries chain 17's
split authorisation (bundle guest v3 + hc_auth), its caps (4 MiB proofs, 20 MiB blocks — room for
three proofs), its validators, bridge state and value, and adds the genesis gas section
(optional, absent from every chain up to and including 17 and hashed in only when present, after
hc_auth). Chain 18 also turns the encrypted memo on (the user's ruling): the cut passes
--envelope-bytes 1860 (the v0.5.10 field — every note-creating envelope exactly 1 860 B,
rand_getLimits.envelope_bytes == 1860, wallets seal the memo form). rand-node genesis --envelope-bytes seals its own --alloc notes in that form; the zUSD carry notes come from
rand-node alloc-note --envelope-bytes 1860 (without the flag it writes the legacy 1 348-B form,
which the genesis refuses), and the cut asserts the field and every alloc note's 1 860 B. The gas
section:
"gas": { "gas_price": "100", "byte_price": "800", "bundle_gas_limit": 20479, "metering": "circuit",
"dynamic": { "target_block_bytes": 10485760, "target_block_gas": 262144,
"adjust_bps": 1250, "min_gas_price": "100", "min_byte_price": "800" } }gas_price,byte_price(decimal strings,> 0): the starting units of RAND per gas and per KiB of call proof plus input envelope. A call's floor is `BUNDLE_BASE + gas_price·GAS_LIMIT- byte_price·⌈bytes/1024⌉
, priced at the proof's own declaredGAS_LIMIT, not the header's ceiling (docs/fees.md` §1.1).
- byte_price·⌈bytes/1024⌉
bundle_gas_limit(exactly20479): every bundle proof's declaredGAS_LIMITmust equal this exactly, or the proof is refused (TxError::BundleGasLimit, permanent). It must begas_max(14, 0, 0) = 20479for today's tier-14 hidden-asset guests (v1, v2 and chain 18's v3), and genesis refuses any other value (GasConfig::check,gas::bundle_gas_limit_pin) — any other value would mean no real bundle could ever be admitted. On a split-authorisation chain the auth proof is pinned the same way with no field of its own:gas_max(10, 0, 0) = 1279(gas::auth_gas_limit_pin,TxError::AuthGasLimit, permanent).metering: only"circuit"parses; the field exists so a later metering scheme has a name.dynamic(optional, Phase 2 — absent means the two starting prices never move):target_block_bytes(1..=max_block_bytes, the ledger's effective cap; halfmax_block_bytesby convention — 10 485 760 for chain 18, whose cap is 20 MiB) andtarget_block_gas(> 0) are what the controller measures fullness against;adjust_bps(1..=5000) is the largest one-block price move, in basis points, in either direction (gas::next_pricecapsusedat2·targetbefore the formula);min_gas_price/min_byte_price(decimal strings, each<=the section's own starting price) are the floors the controller never crosses, and each must satisfymin_price · adjust_bps >= 10000— a floor under that bound could never rise again once reached (docs/fees.md§1.2).- Refused beside an
aggregationsection (GenesisError::DynamicGasWithAggregation) wheneverdynamicis set: a pruned bundle's marker form encodes shorter than its raw form, so a node syncing sealed history would compute a differentbytes_usedthan a live-synced one and diverge on the byte price. A fixed-pricegassection (nodynamic) is not affected — its prices never move, so nothing about sealed-form sync depends on them.
Constraint set 8 changes every verifier key (the cpu AIR itself changes, docs/confidential.md
"Constraint set 8"), so — like every prior constraint-set cut — this is all-stop, all-start, no
mixed-fleet path: a v0.6.3 node cannot verify a cs8 proof and vice versa, and a v0.6.6 node refuses
chains 14–17 by genesis hash (node::CHAINS_THIS_BUILD_CANNOT_RUN). Clients and randscan
rebuild against cs8 first, same rule as constraint sets 5–7: a wallet or explorer built before
the cut cannot prove or verify anything the new chain admits.
rand-node genesis --gas-price <UNITS> --byte-price <UNITS> --bundle-gas-limit <N> [--gas-dynamic <target_bytes>,<target_gas>,<adjust_bps>] writes the section (--gas-price is what turns it on
at all; the other three flags are meaningless without it, and default to 800, gas_max(14, 0, 0) and "off"). rand-node genesis/init print gas: price P/gas, B/KiB, bundle limit N, dynamic: … (or gas: none) on the finished file — the hash that matters, as with every gated
section. deploy/cut-chain18-genesis.sh is derived from deploy/cut-chain17-genesis.sh (the
same snapshot → balances → cut, with chain 17 as the source: its live register, bridge, zUSD
custody and the operator's RAND, scanned from the curated ~/.rand-chain17/alloc-wallets through
an SSH tunnel to obs1 with the v0.6.3 rand; the bridge emitters stay chain 17's — the endpoint
redeploy waits); it writes chain 18's testnet defaults (the JSON above) and asserts the section is
present, dynamic is set with the right target/adjust/floor values (the byte target half of
max_block_bytes), envelope_bytes is 1860 with every genesis note in that form, the guests and
hc_auth are chain 17's, and no aggregation section rides
beside it. SELFTEST=1 runs its assembly on fixtures; DRY_RUN=1 NODE=… WALLET=… runs the real
cut path — rand-node genesis with the gas flags, alloc-note, both inits, which print the gas
section — on inputs synthesised from deploy/genesis-chain17.json, into a temp dir.
deploy/cutover-fleet-chain18.sh (from chain 17's) is the all-stop/all-start roll;
deploy/chain18-bridge-steps.md has the bridge relayer's own rebuild step.
Audit v6 (§8.22): chain 18's byte price follows every transaction's bytes while only a Call pays
it, and has no ceiling — a stream of transfers lifts it without bound. Three optional fields
inside gas.dynamic, each hashed only when set, so chain 18's genesis (which sets none) hashes
and behaves byte for byte as before:
"dynamic": { …chain 18's five fields…,
"max_gas_price": "10000", "max_byte_price": "80000", "byte_load": "paying" }max_gas_price,max_byte_price(decimal strings, each ≥ the section's starting price;GasConfig::checkrefuses less): the ceilings. Pick them as the multiple of the start the chain is willing to charge a call at its busiest — 100× is a bound at ~0.4 RAND for a 1.3 MB call against chain 18's unbounded 1 000×-and-rising.rand-node genesis --max-gas-price --max-byte-price.byte_load: "paying"(rand-node genesis --gas-byte-load paying):byte_pricemoves by the bytes that pay it — each Call's proof and input envelope — not by every transaction's length. Under it a block of transfers gives no byte-price signal; the proposer's reservation of a quarter of the block for pooled Calls (node policy, live on every build from this one) is what keeps a Call in the block then.
Check on the finished file: rand-node init prints ceiling …/…, byte load paying in its gas
line, and rand_getLimits on the first node serves max_gas_price, max_byte_price and
byte_load: "paying". Wallets need no rebuild: the headroom they pay is unchanged and
rand_getLimits keeps every field it had.
Audit v6 (§8.13) said: put no vesting section in any genesis until a revoke's destination is
pinned. It is now — and a revoke has its own nonce and takes the whole unvested part (STAKE-4) —
so the section may ride the next cut that needs it (the v1.0 genesis, after a testnet rehearsal). No chain has carried one; a genesis without it hashes and behaves exactly as
before. What the cut passes, per revocable entry of the --vesting file (docs/vesting.md):
revokers: 1–5 distinct Dilithium2 public keys, none the beneficiary's. Use three, held by three parties; each party generates its own (rand-node keygen) and sends only the public key. The list's order is a term: a revoke names its signers by position.threshold: how many of them sign a revoke — required, no default. Use2.treasury: therand1…address a revoke pays, the only one it may. Create the treasury wallet and back up its key before the cut: the address cannot be changed afterwards, andrand-node genesisrefuses one whose ML-KEM key does not decode.
An irrevocable entry (investors, partners) names none of the three. The old one-key revoker
field is refused. Check before rand-node init's hash is published: every team entry shows
"revocable": true, three revokers, "threshold": 2 and the treasury's address in
rand_getVesting; every investor and partner entry shows "revocable": false. Rehearse one
revoke on the testnet cut with two of the three keys on separate machines (vesting revoke prepare → sign ×2 → submit, inside 256 blocks) and open the note with the treasury wallet.
The chain-18 cut script's vesting carry reads rand_getVesting's amount/claimed/revoked_*
fields, which are unchanged; a cut that carries a register forward must also carry each entry's
revokers, threshold and treasury, which the genesis file holds, and must not re-list a
revoked entry on its old schedule (its vested is frozen at revoked_at, and what is left after
claimed and revoked_out is the treasury's, not the holder's).
What it is. Votes, new-views and proposals have signed the genesis hash since
consensus_domain: 1; v2 registrations and the vesting messages bind it too. Everything else a
user or an operator proves or signs bound the chain id alone: the eight-word binding every
bundle, auth and call proof is made over (Transaction::binding, call_binding) and the signed
messages of a faucet Mint, Unbond, Withdraw, an RPL TokenMint/SetAuthority, the
aggregator register actions and the bridge governance actions (pause, unpause, register, list,
the two rotations). Two chains sharing a chain id — a re-cut, a private copy — would accept each
other's proofs and signatures; that chain ids 14–19 never repeated was the only protection
(deploy/lib/cut-policy.sh now refuses a reused id, OPS-7). The top-level genesis field
binding_domain: 1 puts the genesis hash first in every one of those preimages under a fresh tag
(rand-tx-bind-2, rand-call-bind-2, rand-mint-3, rand-unbond-2, rand-withdraw-2,
rand-rpl-mint-2, rand-rpl-authority-2, rand-aggregator-{register,unbond,withdraw}-2,
rand-aggregate-5 (it was rand-aggregate-3 until the aggregate signed its payout_total, 2026-10-09; the chain-id form is rand-aggregate-4), rand-aggregate-bind-2, and the bridge's …-2 fixed layouts with the 32-byte
hash between the tag and the chain id — crates/randprotocol-core/src/types/binding.rs,
bridge/gov.rs). The binding is a public input of the proofs — the wallet, a prover service
and the ledger compute it and hand it to the circuit — so nothing in any guest or verifier key
moves (guest_provenance stays green). A genesis-bound registration is exactly the v2 message,
whether or not staking.registration_v2 is also set. Absent or 0: chains 14–19's messages,
byte for byte, pinned by the_chain_id_domain_is_the_messages_chain_18_signs_byte_for_byte.
What the cut passes. rand-node genesis … --binding-domain 1 — from chain 20, the first
chain that can carry the field: chain 19 is the v0.6.7 re-genesis, cut without it, and is pinned
with 14–18 in CHAIN_ID_BINDING_CHAIN_IDS. The field is tagged and
appended to the genesis hash after gas, only when present; rand-node genesis warns when a
chain id outside 14–19 is cut without it, because no wallet can transact on such a chain:
randprotocol_client::CHAIN_ID_BINDING_CHAIN_IDS (= 14–19) pins which chain ids get the
chain-id form, and a wallet uses the genesis-bound form on every other id, over the genesis hash
its note store is bound to — never the node's rand_getLimits.binding_domain claim, which it
reads only to refuse early (the envelope_bytes trap, issue #64). A node lying about the hash
can only make that wallet's own transactions invalid (a genesis-bound binding verifies on exactly
the chain whose genesis hash it carries); it cannot make them valid on a second chain, which is
what BIND-1 is about. A cut without the field must add its chain id to that list first
(every_committed_genesis_without_binding_domain_is_pinned fails until it does) — do not: cut
with the field.
Every signer of an action message must be on a build with the field before the cut, as with
every prior message change: the wallet (rand — wallet::binding_domain), the operator commands
(rand-node register/unbond/withdraw/aggregator … — RpcClient::binding_domain), the aggregate
daemon, and the bridge repository's rand-bridge-gov, whose pause, unpause, register and
list signatures must be made over the …-2 layouts (docs/bridge.md §21, "BIND-1") — a pause
file signed in the chain-id form is refused BadPauseSignature on a binding_domain: 1 chain.
The clients repository (wasm core, the apps) and randscan must carry the same chain-id rule.
Already genesis-bound, so not re-domained: AdmitValidator's votes (rand-admit-validator-1 ‖ genesis ‖ candidate, on every chain) and SlashEquivocation's evidence (two block signatures
under the consensus signing domain; genesis validation refuses staking.slashing without
consensus_domain: 1, so that evidence always carries the genesis hash).
Not re-domained (deliberately, out of this repository's hands): the mint co-signature
rand-bridge-pq-cosign-1 ‖ chain_id ‖ mu the guardian daemons sign. It still binds the chain id
only, so a same-id copy of a chain that also copies the guardian set would accept a deposit's
co-signatures; binding it needs the guardian daemon (another repository) to sign
rand-bridge-pq-cosign-2 ‖ genesis ‖ chain_id ‖ mu and the ledger to verify it under the flag.
Recorded in docs/bridge.md §21 as the open half; the attest transaction's own fee bundle is
genesis-bound, the attestation body inside it is not.
Tests that pin it: core ledger::bind_tests (a transfer, an auth proof, a hardened call, a
mint, unbond/withdraw, a registration, each made for genesis A refused on B with the same chain
id, and the chain-id form refused under the flag), tokens::action_tests::under_binding_domain_…,
aggregation::admission_tests::under_binding_domain_…, bridge_gov::tests::under_binding_domain_…,
genesis::tests::a_genesis_with_binding_domain_1_…; node a_restart_restores_the_binding_domain;
client the_binding_domain_is_the_chain_ids_never_the_nodes_claim; split_auth (chain 20, real
proofs under the flag) and wallet_flow (chain 7 under the flag); cluster and zusd_e2e moved
to chain id 18 so their hand-signed chain-id messages stay today's.
The audit's registry names the four halves of CH-8 without their text; what each is in the tree today, as re-read on 2026-10-01:
- 8a, chain-id binding — BIND-1 above: closed by
binding_domain: 1at the next cut; until then chains 14–19 bind the chain id only, as before. - 8b, the faucet key — a faucet mint is signed by the node's validator key
(
Node::mint,crates/randprotocol-node/src/node.rs:Keypair::from_seed(self.cfg.seed)), the one key that also signs its votes. What bounds it: the message is domain-separated from every consensus message (rand-mint-2,rand-mint-3under the flag — a mint signature can never be read as a vote or a new-view, and vice versa); the ledger admits a mint only from a register row and, understaking.faucet_minters, only from the genesis list (ledger/mod.rs,MinterNotValidator/MinterNotAllowed);rand_mintis refused on the public listener (rpc.rs,PUBLIC_METHODS) and metered on the operator's; the faucet is off on mainnet by the cut policy. Not changed: a separate faucet key would be a genesis change (afaucet_mintersentry that is not a validator) and the faucet is a testnet-only feature — recorded, not fixed. - 8c, quick-verify mode —
rand-node run --verify-chain quickis the default (main.rs,verify_chain;storage.rs,VerifyMode): at startup every block's hash links, indexes, QC/block match and tx roots are checked and the ledger replayed against every header's state root, with bundle proofs re-verified; QC votes are not verified —fulldoes that too. It is a check of the node's own disk, not of anything a peer sends (synced blocks are fully verified on arrival,apply_synced); a QC on disk that no quorum signed can only have been written by whoever writes the data directory. On a pruned node the check is structural over the retained range (v0.5.7). Not changed:fullcosts a Dilithium2 verify per vote per block (26 validators × 18 signatures × ~250 000 blocks on chain 18's archive) and the default staysquick; runfullafter any restore from a foreign copy of a data directory. - 8d, the demo guest's wrapping add —
circuits/guests-compiled/fib/src/main.rscomputesfib(n)withu32::wrapping_add, sofib(48)and up wrap silently. It is the zkVM's demo and test guest (randprotocol-zkvm-demo,tests/executor.rs), vendored from the circuits repository (deploy/sync-zkvm.sh), deployed on no chain, and its wrapping is the RV32addit compiles to — the guest is a fixture, not a program anyone is paid by. Not changed here (vendored); a guest that must not wrap useschecked_addand halts onNone.
A group inside the genesis bridge section, beside rules_v2: "rotation": { "delay_secs": 86400, "needs_possession": true } (docs/bridge.md §21.5). needs_possession makes every PQ-set
and pause-key rotation carry each new key's own signature over the rotation message (the V2
actions, variants 30/31); delay_secs (1 s to 30 days) makes an accepted rotation pend until a
block's time reaches effective_at_secs, while the current pause key can cancel it
(CancelRotation, variant 32). Hashed after binding_domain, in the bridge root under
rand-bridge-rotation-1 and stored under bridge_rotation, all only when present — chain 18 is
byte for byte unchanged. What the cut passes: the group in the bridge JSON the cut script
splices in (rand-node genesis writes no bridge section, so there is no flag); recommended
delay_secs: 86400, needs_possession: true. Before the first real rotation, run the
rehearsal checklist in docs/bridge.md §21.5 on a private chain; the signer is
rand-node bridge-gov (this repository). A rotation's activation spends a rotation_nonce, so
relayers holding attestations co-signed by the old set must re-collect co-signatures from the new
set after effective_at_secs.
The user's decision (2026-10-01): take the bridge fee in zUSD on Rand, both ways, not in USDT on the
host chain — the fee income is then shielded. docs/bridge.md §25 is the rule. The cut splices into
the genesis bridge section (there is no rand-node genesis flag — the cut script writes the bridge
section):
"fees": { "mint_bps": 10, "burn_bps": 10, "recipient": "rand1…" }- Recommended:
mint_bps: 10, burn_bps: 10(0.1 % each way, the endpoints' old skim moved on chain). At most 100 each. recipient: a dedicated treasury key, generated off the laptop for this purpose (rand keygen), never a validator's, guardian's or relayer's wallet — every fee note is visible to whoever holds its viewing key, and the address is fixed for the chain's life. Back it up before the cut;rand-node initrefuses an address whose ML-KEM key is not one.- Before or with the cut: the guardians and relayer sign the burn body as before — under the
group it already carries
release_amount(rand_getBridgeBurn.release_amount); a relayer that recomputes the release from the burn transaction'samountmust switch to the body's. Decide whether the endpoints keep their own 10 bps skim on top (the chain's fee does not replace it by itself). Rebuild the wallet apps against v0.6.8, or deposits on the new chain show the gross until their envelope opens (the relayer'srand bridge-mintseals for the net). - After launch:
rand_getBridgeState.feesreads the group; a 1 USDT deposit leaves 0.999 zUSD with the depositor and the treasury'srand balanceshows 0.001 after a sync.
RPL-2's program cells, program vaults and Invoke (variant 33) switch on with a top-level
"program_state": { "cell_fee": <units> } (a JSON number) (docs/program-state.md). Chain 19 is cut on v0.6.7
without it; chain 20 is the first chain that can carry it. Hashed last (after
proof_window_blocks), folded into the state root as rand-state-8 and persisted under
META_PROGRAM_STATE, all only when present — chain 18 is byte for byte unchanged. What the cut
passes: rand-node genesis --program-state-cell-fee <UNITS> (at most 1 000 RAND), which needs
--tokens, --gas-price, --hardening-v6 and --auth-guest (with --bundle-guest v3), each
refused by name; Genesis::validate also requires confidential. Check rand_getLimits.program_state
after the cut. randscan and the clients render the invoke kind and its payout notes before or
with it (docs/rpc.md, changelog).
Chain 21 carries it (the user's decision, 2026-10-11): create_fee "1000000000" (1 RAND)
and one seeded account, the foundation treasury — 2 of 3, its balance new issuance. The signers'
keys and the amount come from the operator's TREASURY file; deploy/cut-chain21-genesis.sh
writes the section, derives the id offline and checks it against what genesis --multisig
prints ("The next cut: the fees section (chain 21)", the chain-21 sections, below).
Multisig accounts (docs/multisig.md, spec docs/superpowers/specs/2026-10-08-multisig-design.md)
switch on with a top-level "multisig": { "create_fee": "<units>", "accounts": [ … ] }. Hashed
after every other section, folded into the state root as the wrapper rand-state-multisig-1 and
persisted under META_MULTISIG, all only when present — chain 20's file builds chain 20's hash
unchanged. Until a cut carries the section, a node refuses all four actions (tags 40–43, after the perps actions' 34–39) with
UnsupportedAction("multisig"): dormant on chain 20.
What the cut passes: rand-node genesis … --multisig multisig.json --chain-id <N>. The id is
derived from the chain id, so write the file for the chain being cut. What to record: the
command prints multisig: create fee N RAND, K account(s) and one
multisig <id> <balance> RAND, T of N signers line per account — copy every id into the cut
record, and rand-node multisig status <id> after launch must show the same signers (in the
file's order), threshold, nonce 0 and balance.
The units trap: balance and create_fee are in units (1 RAND = 10⁹), decimal strings. Check
the printed RAND balance against the signed allocation table; a missed factor of 10⁹ is the
classic mistake. Multisig balances count toward the supply bound beside notes, stakes and vesting
(SupplyOverflow). Collect each signer's public key from the signer (never generate it for them),
use threshold < n, and check rand_getLimits.multisig and rand_getSupply.multisig_issued
after the cut.
Audit v4's STAKE-2 (docs/staking.md §2): a per-epoch faucet budget, a bond activation delay and
the rule that a faucet and a bridge exclude each other. Genesis-gated — rand-node genesis never
writes it, so a file without it (chain 14's) hashes byte-for-byte as before; the cut script splices
it in beside the bridge section, and rand-node init on the finished file prints the hash that
matters:
"staking": { "faucet_budget_per_epoch": "100000000000", "bond_activation_epochs": 2 }faucet_budget_per_epoch is in RAND's base unit as a decimal string (100 RAND above — one Mint's
worth per 1000-block epoch); bond_activation_epochs is how many whole epochs a new bond waits
past the boundary it would have joined at (0 = today's rule). On a bridged chain set
faucet: false — a faucet beside a bridge is refused at init once the section is present —
unless faucet_recipients limits the faucet to named wallets (the STAKE-2 fields above). The
section moves the state root domain to rand-state-5 and the validator leaf to
rand-validator-leaf-4 and appends the bond queue's root to the state root, so it ships with a
chain cut, never as a same-chain update; a node restores it from the genesis file on every restart
(reload_ledger), never from the database. The bond queue itself is state, persisted as
META_BOND_QUEUE beside the supply counters.
Cutting and bringing up the chain-9 fleet, in order. Everything here is the hard fork it is: the wire format, block rules and consensus all change, so every node runs the same build, cut from the same commit.
- Measure the activation values on the production build the fleet will run (never from a
note, never from another build): the admitted shape is the fleet's 2-in/2-out bundle at its
production landing — tier, the six declared log-heights, and the aggregate program digest,
computed from the shape alone by the startup key-build (spec §2.3; the ~30–70 s 2²¹ key-build
every node then pays once at startup). The fleet's own measured classes for the bundle are
program 12, input 10, keccak 0, sha256 0, public 2, mem 16; the tier and the digest are the two numbers activation must measure — the digest through the executor'saggregate_program_digest, the tier from a production bundle proof's header. - Cut the genesis:
deploy/cut-chain9-genesis.sh, mirroring chain-8's validator and alloc mechanics with--chain-id 9and the section. The admitted shape'shcis the keywordhc_bundle(the genesis command substitutes the build's pinned guest digest — the cut cannot carry a stale one); the digest and tier come from step 1. The script ships with the test-profile values marked FILL-AT-ACTIVATION; genesis validation refuses a zero digest, so an unedited placeholder can never reach a fleet. - Distribute the file byte-identically (re-cutting re-randomises the deposit notes, so the hash differs every run — cut once, copy everywhere).
- Bring the fleet up as usual; each node logs the startup key-build's wall time once
(
aggregation: aggregate program built and the verifier keys for tiers [20, 21, 22] warmed). - Aggregators register (
rand-node aggregator register --bond --payoutprints the signed registration; the bond burns through the wallet'ssubmitas the register bundle's burn) and runrand-node aggregate --watchon a proof machine (≥ 256 GB at N=1 after the phase-2 row cuts, a projection until the tier-20 proof runs —docs/node-hardware.md§4; the GPU node for N≥2 — seedocs/aggregation.md's machine classes), pointed at any fleet RPC. 5a. On a tree chain (a genesis with anaggregation_treesection,docs/aggregation.md§3.8; none yet): the section's values are measured like step 1's — the leaf and each listed level's heights read off a leaf, steps and root proved on the production build, the pins (vk_leaf, each level's program digest and key) from that build's rebuild, the list long enough to end on a fixed point ifmax_depthruns past it. Pending (droplet; circuits docs/08 §4): the production tree is not proved yet, so there are no production values to cut. The section goes in withrand-node genesis --aggregation-tree <TREE.JSON>, which runs the same pin check every node runs at startup and writes nothing on a mismatch.TREE.JSONis the block circuitsproduction_tree_genesis_valuesprints between its== aggregation_treemarkers, saved as is: theTreeConfigJSON, digests as four u64 limbs (docs/aggregation.md§3.8 has the form and thestep_tiers/step_digests/step_keysmapping). Do not transcriberand_status.aggregation.tree's hex into it; that is the same section for humans. Every node checks every pin at startup and refuses to start on a mismatch (aggregation_tree pins: …; refusing to start); a healthy node logsaggregation_tree pins: okbefore the warm thread, thenaggregation_tree: N root keys warmed. Aggregators runrand-node aggregate --watch --layout treeon a host of the step class (circuits docs/08 §3: tier-21 production steps, ≈ 122–197 GB projected at rate ¼ — the 256 GB droplet class; the measured number is pending the droplet), pointed at any fleet RPC. The ops check isrand_status.aggregation.tree(the section verbatim) beside the startup log line. - Ops checks:
rand_status'saggregationsection (registered, unsealed, the schedule index),rand_getUnsealedfor the work list,rand_getSupplyfor the four counters (subsidisedagainstsealed_blocksis the schedule audit). Archive nodes run with--keep-raw-proofs; everyone else lets the pruning pass reclaim sealed bundles after the 256-block window.
| symptom | action |
|---|---|
height not advancing, peer_count low |
check bootstrap addresses and port 30303; rand peers |
| height not advancing, peers fine | fewer than 2/3 of stake online: start the missing validators |
CORRUPT CHAIN in the log at startup |
nothing; the node truncated and is resyncing. To inspect first, run rand-node verify before starting |
node refuses to start: already initialized with a different genesis |
the datadir belongs to another chain; use a fresh --datadir |
| a validator warns it is not in the validator set | the key is not in genesis; it runs as an observer |
| views advance but nothing commits after nodes restarted | validators are waiting for uncommitted blocks behind the newest certificate that no reachable peer holds; since f5b8dfd they fall back to the committed head after failed fetches (log: falling back to the committed head QC). Make sure every node runs the same build: the sync protocol is chain-scoped and builds cannot fetch across versions |
| a peer keeps connecting but never helps | it may run another chain or an older build; gossip topics and the sync protocol are per chain id, so it is harmless but useless |
Chain 20 (like 15–19) runs the faucet for an allow-list only: genesis
staking.faucet_recipients names 16 wallets, and the ledger refuses a Mint to any other address
(rand_status.faucet: true says the faucet exists, not that anyone may mint). rand_mint is also
not on the public listener (rpc::PUBLIC_METHODS) nor through https://rpc.randprotocol.org (the
website's sale proxy allow-list). A session or a developer that needs testnet RAND asks the operator,
who sends it from an operator wallet with rand send (a v0.6.8 rand; on 2026-10-01, 300 RAND each
to durian.market's treasury and to example-programs). The RPL-2 reads (rand_getProgramCell,
rand_getProgramCells, rand_getProgramVault) are on the node's public listener but were not
on the website proxy's allow-list at chain 20's launch; randprotocol.org e77ec7a (2026-10-01,
server/sale/src/rpc.rs RPC_ALLOWED) added them, and rand program invoke/state/vault work
through the public URL since. A new public method needs that list too.
What it is. A bundle's anchor must be one of the last 256 block-end roots
(ledger::ANCHOR_WINDOW) and its time at most 256 blocks behind the height
(ledger::TIME_WINDOW). At chain 18's 1.17 s blocks that is ~300 s for the whole trip — the auth
proof, the bundle proof, the wallet's verify, submission and inclusion — and a delegated bundle
proof on a loaded single-core prover took 294 s and was refused (time 102225 is outside [102230, 102486]). The top-level genesis field proof_window_blocks replaces both windows (they exist
for the same reason) with one value in [256, 4096]. Hashed under the tag proof_window_blocks,
after bridge_rotation, only when present; never in the state root (the anchor deque it sizes
never was); reload_ledger restores it and re-reads the deeper anchor rows on every restart;
served as rand_getLimits.proof_window_blocks.
Recommended: --proof-window-blocks 1024 for chain 20 (rand-node genesis --proof-window-blocks 1024, or "proof_window_blocks": 1024 spliced in): ~20 minutes at
1.17 s blocks, room for any prover the pool or a user runs, while a stale anchor still dies within
the hour. Chain 19 (v0.6.7, being cut now) carries no field and stays 256/256. The chain-20 cut
script (deploy/cut-chain20-genesis.sh) asserts the field, and its check-limits step reads rand_getLimits.proof_window_blocks == 1024 back after launch. Every validator
must run a build that knows the field before a genesis carries it — a v0.6.7 node does not parse
it (and would compute a different genesis hash).
What it is. The token registry's root was a merkle root over every token's leaf, recomputed
from every leaf at every state root, and the node rewrote the whole registry blob (META_TOKENS)
at every commit, whatever the block changed — bounded by tokens.max_tokens (4 096) and the
burned registration fee, so a Low. The tokens-section field incremental_root: true makes the
root an incremental merkle commitment over rand-token-leaf-2 leaves (one leaf and log n nodes
re-hashed per changed token; rand-token-registry-4; the state root re-domained
rand-state-tokens-1, innermost of the audit-v6 wrappers) and the store one row per token under
a tokens_header key, rewriting only the rows a block changed (docs/tokens.md §17). Hashed under
the tag tokens_incremental_root, after bridge_fees, only when true; reload_ledger restores
the flag from the file on every restart; verify --mode full rebuilds the registry from its rows
and compares it with the replay; served as rand_getTokens.incremental_root. No validity rule
changes. Node-only on every chain, already: the legacy root is computed once per change and reused.
Recommended: on, for the next cut (rand-node genesis --tokens tokens.json --tokens-incremental-root, or "incremental_root": true in the tokens file the cut script
splices). Chains 18, 19 and 20 carry no field and hash, root and store exactly as before. Every
validator must run a build that knows the field before a genesis carries it — a v0.6.9 node does
not parse it (deny_unknown_fields on the section) and would compute a different genesis hash.
After launch: rand_getTokens.incremental_root == true, and a validator's meta family holds
tokens_header and one token/… row per listed token and no tokens blob. Rolling back a node
to a build before this one on such a chain is not possible (it cannot parse the genesis) — the
same rule as every genesis field before it.
What it is. A top-level genesis section, "fees": { "burn_base": true }, with three optional
booleans (docs/fees.md §1.3). burn_base destroys every bundle's BUNDLE_BASE instead of paying
it to the proposer: the proposer keeps the tip (fee − BUNDLE_BASE), or nothing at inclusion on an
aggregating chain, whose excess is bucketed as before; the base joins rand_getSupply's burned
and base_fees_burned. subsidy_net_of_fees pays an aggregate's subsidy from its proving shares
first and mints only the shortfall (the note is max(schedule, shares)); it needs an aggregation
section, or rand-node init refuses the file (SubsidyNetOfFeesWithoutAggregation). burn_floor
(issue #135) widens burn_base's burn to the bundle's whole settled floor — the base plus a
Deploy's per-word term, or a Call's tier-exact gas and byte terms — so the proposer keeps only
fee − floor; it needs burn_base: true, or rand-node init refuses the file
(BurnFloorWithoutBurnBase). Absent, or present with no true flag, is every chain's rule so far
and commits nothing; a true flag is hashed after the tokens section's bytes under fees and its
own tag, so it ships with a chain cut, never as a same-chain update. Never in the state root: stored
at genesis under META_FEES (JSON; written on every new database, the default {} without a
section), and reload_ledger sets it again from the file on every restart; served as
rand_getLimits.fee_rules.
Recommended for chain 21: {"burn_base": true, "burn_floor": true} (the user's decision,
2026-10-08), the faithful EIP-1559 form of docs/fees.md §1.3. Under it the whole floor a bundle
was checked against is destroyed — BUNDLE_BASE (0.001 RAND) for a transfer, a bond, a token action
or an attestation; the base plus 100 000 units a word for a Deploy; the tier-exact
1 000 000 + gas_price · GAS_LIMIT + byte_price · ⌈bytes / 1024⌉ for a Call under chain 20's gas
section; BRIDGE_BURN_FEE for a BridgeBurn; an Invoke's cell fee with it — and the proposer keeps
only the tip above it, fee − floor (a 0.0012 RAND transfer burns 0.001 and tips 0.0002; a 0.0041
RAND tier-14 Call burns 0.0040639 and tips 0.0000361). With chain 20's floating prices (§1.2) that
is the point: a proposer gains nothing from a block that lifts a price, and a busy chain shrinks the
supply instead of paying its proposers more. Nothing a sender pays changes — the floors and the
wallet defaults are the same numbers; only where the floor goes differs. What every client sees:
rand_getLimits.fee_rules = {"burn_base": true, "subsidy_net_of_fees": false, "burn_floor": true} (null on chains 14–20), and rand_getSupply.base_fees_burned, a decimal string inside
burned and on the right of the supply identity, "0" at launch and growing by each included
bundle's floor with invariant_holds still true. The wallets, randscan and the bridge site should
read both: a wallet's fee preview should say the floor is burned, the explorer and the bridge
site's supply panel should show base_fees_burned beside registration_fees_burned; a client that
does not know either field still decodes the reply (each is an added key).
Not subsidy_net_of_fees. It needs an aggregation section, and this build refuses any
genesis with one at startup (check_build_runs_genesis): the admitted shapes and the recursion
fixtures were measured for the retired 2-in-2-out guest and must be re-measured against the
hidden-asset bundle (b053a76) before aggregation is activated. Until then the flag can only be
refused (rand-node genesis --fees errors without --aggregation), so chain 21 does not carry it,
and the cut script refuses a genesis that sets it, naming that reason. It rides with the cut that
re-activates aggregation.
Rolling it out. rand-node genesis --fees FEES.JSON writes the section (docs/cli.md).
deploy/cut-chain21-genesis.sh — derived from chain 20's script, chain 20's shape and value cut
from a snapshot of chain 20 — writes fees.json itself ({"burn_base": true, "burn_floor": true}), passes it as --fees, asserts the section on the finished file, refuses
subsidy_net_of_fees, and proves the section is part of the genesis hash by re-deriving the hash
with the section removed, and with burn_floor removed, and refusing if either does not move. The
real cut refuses a rand-node without --fees (any build before d6cc16f5, v0.7.1 included);
DRY_RUN=1 skips the section, loudly, on such a binary. Every validator must run a build that knows
the section before a genesis carries it — an older node does not refuse the file: Genesis has no
deny_unknown_fields, so it silently ignores the section, derives a different genesis hash and so
cannot join the chain. So every node — the 26 validators, both archives, and any RPC node the
website, randscan or the bridge relayer reads — runs the d6cc16f5+ build from chain 21's first
block. A signer binds chain 21's genesis hash without re-deriving it from the file: the rand
wallet (and the relayer, which is a rand) reads it from the node (rand_getGenesisHash, under
binding_domain: 1), and the offline operator commands (rand-node admit sign) take it as
--genesis-hash, so a v0.6.8+ rand still signs for chain 21 (check any other signer, e.g. the
bridge repo's rand-bridge-gov, for where it takes the hash before the cut).
Ship them the same build anyway, in the cut's clients: line: only it knows fee_rules
(RpcClient::fee_rules; no wallet fee preview says yet that the floor is burned — a follow-up),
and the clients repo apps, randscan and the website WASM vendor their fullnode version as before
(the chain-20 record's lesson).
After launch: NODE=… deploy/cut-chain21-genesis.sh check-limits <first node's RPC> <genesis file> reads rand_getLimits.fee_rules.burn_base == true and .burn_floor == true (with
subsidy_net_of_fees == false and the split and dollar-subsidy fields null),
rand_getSupply.base_fees_burned == "0" and invariant_holds == true, beside every value carried
from chain 20 and the two sections below. Run it before the first bundle lands, or with
BASE_FEES_BURNED_ANY=1 once one has.
Chain 21 switches on three genesis sections, not one. Each is a constant of the cut script (not
overridable from the environment), asserted on the finished file, proved to be part of the
genesis hash (the cut re-derives the hash without each section and without one optional field of
each — fees.burn_floor, perps.min_deposit, multisig.create_fee — and refuses if any does
not move), and read back by check-limits.
perps (docs/perps.md): one market on zUSD.
| field | value | why |
|---|---|---|
collateral_asset |
1 |
zUSD, FIRST_TOKEN_INDEX: chain 20's genesis lists exactly one token, and the cut asserts the finished file's tokens.tokens[0] is zUSD. The supply audit does not cover a token collateral (v0 limit M6); chosen knowing that. |
| market 0 | BTC-PERP, lot 100000, tick 100000000, max_leverage 20, maintenance_bps 250, taker_fee_bps 5, maker_fee_bps 2 |
lot is 0.0001 BTC (base atomic units, BASE_UNIT = 10⁹ = 1 BTC). A price is collateral units per BASE_UNIT, and zUSD has eight decimals (BRIDGE_DECIMALS): tick 10⁸ is $1 a BTC (10⁹ would be $10). perp-core's initial margin is ⌈notional / max_leverage⌉, 500 bps at 20x; maintenance must be below it or a max-leverage position opens liquidatable — the engine does not refuse such a spec, the cut does. |
max_tier, max_block_inputs |
18, 24 | the documented production sizing: durian.market's gated case block-24-14liq (24 inputs, a close that liquidates 14) is 212 018 cycles, inside tier 18 with 15 % headroom; 25 is refused. |
max_window_blocks |
64 | the ceiling; 64 close-only blocks do not fit tier 18 (≈ 25 k cycles a close), so perp-prover run halves its window until one fits. |
min_deposit |
100000000 | 1 zUSD (eight decimals). |
engine_hc |
f286a611…1573 |
durian.market's pinned artifacts/perp-engine.image.bin; the cut runs rand perp image-hc --image $PERP_IMAGE and refuses any other. |
genesis_root |
derived | $PERP_PROVER genesis-state over the market list, then rand perp genesis-root --state; printed as PERPS GENESIS ROOT for the cut record. The real cut refuses without PERP_PROVER (durian.market's perp-prover, built from the commit that pinned the image). |
The section is spliced, not passed as rand-node genesis --perps: the base file lists no
token (zUSD is a bridged token and is spliced in with the bridge section), and Genesis::validate
refuses collateral_asset 1 on a file whose token list is empty. rand-node init on the
finished file runs the whole of Genesis::validate over it (PerpsConfig::check, the
confidential/tokens/gas/hardening_v6/hc_auth requirements, the collateral index);
chain 20's shape already has all of them and binding_domain 1, which perp signatures bind to.
Operations perps adds: NTP on every validator (a perps chain's block time is consensus input,
"Block time is bounded"); a perp-view service (perp-prover serve) and a prover host
(perp-prover run, ~22 GB of RAM a production-FRI proof; proving is open and unpaid in v0, and a
withdrawal is paid only when the proofs reach its block).
multisig (docs/multisig.md): create_fee "1000000000" (1 RAND, the recommended value;
0 is refused at this cut) and one account, the treasury: exactly 3 signers in the file's order,
threshold 2, a fresh 32-byte salt per cut (printed), the balance new issuance — never carried
from a chain-20 wallet. The operator's TREASURY file (default ~/.rand-chain21/treasury.txt)
holds public keys only: signer <name> <Dilithium2 pk hex> three times, threshold 2, balance <RAND decimal> (converted with the script's exact units(), no float). A signer key that is a
validator's, a bridge guardian's or the pause key is refused, and so is a key listed twice. The cut
derives the id offline (rand-node multisig id), requires rand-node genesis --multisig to print
the same id and balance, prints TREASURY ID <id>, and refuses a cut record whose reason: or
carries: line does not name the treasury (require_cut_record_mentions,
deploy/lib/cut-policy.sh). The carry rule is unchanged — Σ RAND notes == Σ scanned balances —
and the treasury is printed beside it as new issuance; notes, stakes and the treasury together
must fit a u64. check-limits reads rand_getLimits.multisig (create_fee "1000000000"),
rand_getSupply.multisig_issued (the treasury's balance) and rand_getMultisig <id> (the
signers in the file's order, threshold 2); perps: rand_getLimits.perps (collateral 1, tier 18,
64 blocks, 24 inputs, 8 payouts, min deposit "100000000") and rand_getPerps (the market, the
pinned engine_hc, genesis_root, and at launch proved_height 0 with proved_root ==
genesis_root — run it before the first state proof).
hc_auth_multisig (private multisig phase 4, feat/private-multisig 712c6336): the multisig
auth guest's digest, 9705f8e2f1bc8561848b29cb8efd57c37e3e39788fa58a5f03304d8019668247, written
by rand-node genesis --auth-multisig-guest (it requires --auth-guest) from the build itself —
never typed. The cut asserts it is that value (EXPECT_HC_AUTH_MULTISIG; any other refused), the
file's last field (hashed last, a tagged append after aggregation_tree, so chain 20's hash
does not move), hc_auth present and a different guest, and the binary's printed value equal to
the written one; it is excused from the chain-20 comparison, probed (a seventh variant, without
it), and read back by check-limits from rand_getLimits.hc_auth_multisig and
rand_status.hc_auth_multisig (private-multisig Task 5's fields; a build without them fails the
read-back). The cut record's reason: or carries: line must name it (hc_auth_multisig or
"private multisig"). The real cut refuses a rand-node without --auth-multisig-guest; DRY_RUN
skips the field, loudly (AUTH_MULTISIG SKIPPED). The limits move with it: max_proof_bytes
6 MiB (6291456; chain 20's 4 MiB) — a multisig auth proof plus its bundle is estimated near 4.8 MB
at production FRI, and the node refuses a cap below 4 MiB beside the field — and max_block_bytes
19 MiB (19922944 = 3 · 6 MiB + 1 MiB, the three-proof Call rule; chain 20's 20 MiB), with
gas.dynamic.target_block_bytes following at half, 9961472. Both are asserted at those values
unless LIMITS_CHANGED=1, and check-limits reads them back. 6 MiB is pending the measured
production msauth proof size (the private-multisig session's Task 3); a measurement re-decides
it here and in the script together. Every chain-21 node needs the msauth verifier key
/usr/local/bin/msauth-vk-production.bin (23 754 B, SHA-256 68facb06…cd30, committed at
crates/randprotocol-zkvm/keys/) or it refuses to start — see "The msauth verifier key" above;
the DRY_RUN loopback node needs it
too (RAND_MSAUTH_VK=<the committed blob>).
What it is. Two optional numbers in the same fees section (docs/fees.md §1.3,
docs/compute-optimization.md §6.2–§6.3), each only on a chain with an aggregation section
(rand-node init refuses either without one: ProposerShareWithoutAggregation,
ProveBaseWithoutAggregation; a share over 10000 is ProposerShareOutOfRange; prove_base: 0 is
ProveBaseZero — leave the field out).
proposer_share_bps keeps that share of the base to the proposer at inclusion and buckets the rest
beside the excess as proving share (swept back to the proposer if no aggregate covers it);
prove_base raises every bundle's floor by a proving share bucketed whole and never burned. Each is
hashed only when set, after burn_floor's bytes (proposer_share_bps ‖ be32, prove_base ‖ be64),
so it ships with a chain cut. Served as rand_getLimits.fee_rules.proposer_share_bps and
.prove_base; rand_estimateFee includes prove_base.
Recommended values, deferred. "proposer_share_bps": 4000, "prove_base": 600000 (40 % of the
base to the proposer, 0.0006 RAND proving floor) — the proposal's numbers — once aggregation is
admitted on the chain being cut. Until then no cut should carry either: both need the aggregation
section, which waits on the production-proof measurement (docs/aggregation.md, "Before enabling
aggregation"). After launch: fee_rules serves both, rand_estimateFee {"kind":"bundle"} answers
1600000, a proposer's rewards grow by 400 000 per included transfer, and invariant_holds
stays true through covers and sweeps.
What it is. An optional sub-section of the same fees section (docs/fees.md §1.3), only on
a chain with an aggregation section (rand-node init refuses it without one:
UsdSubsidyWithoutAggregation; a zero usd_micros_per_sealed_block, max_subsidy_per_block,
price_max_age_blocks or initial_price_micros — all four required — is UsdSubsidyZero naming
the field). While the
validator set's voted RAND/USD price is fresh the sealing subsidy's schedule is the dollar target
converted at it, capped at max_subsidy_per_block; otherwise subsidy(n). The price is set by
SetRandPrice (wire tag 44 — every node must be on a build that decodes it before the chain's
first vote), quorum-checked like AdmitValidator, at most a factor of two per update. Hashed only
when set, after prove_base's bytes, so it ships with a chain cut; the state root gains the
rand-state-price-1 wrapper on such a chain only. The same build changes the Aggregate wire
encoding on every chain (payout_total, signed under rand-aggregate-4): no chain has carried an
aggregate, but every node and aggregate daemon on a chain cut with aggregation must be on it. Served as rand_getLimits.fee_rules.usd_subsidy
and rand_getRandPrice; the operators' tooling is rand-node price status|sign|submit.
Recommended values, deferred. For the fee study's $345 a day at 1.2 s blocks:
"usd_subsidy": { "usd_micros_per_sealed_block": 4791, "max_subsidy_per_block": 300000000, "price_max_age_blocks": 72000, "initial_price_micros": <the cut-day price> } — 0.03194 RAND a
block at $0.15, the 0.3 RAND cap binding below $0.016, a day's grace before a lapsed vote reverts to
the RAND schedule. Like the split, once aggregation is admitted on the chain being cut: no cut
should carry it before the aggregation section, which waits on the production-proof measurement
(docs/aggregation.md, "Before enabling aggregation"). After launch: rand_getRandPrice answers
the genesis price at height 0, nonce 0; rand_getEmission.subsidy.current is "31940000" at that
price; a first rand-node price submit … --nonce 1 signed by more than two thirds of the weight
moves it, and invariant_holds stays true through the aggregates that follow.
Audit v6 (2026-09-30) §8.5. Each is optional, absent from every genesis through chain 18, and committed to the genesis hash under its own tag — after every tag that existed before it — only when set; a file without them hashes and runs byte-for-byte as before.
staking.max_stake_entry_bps_per_epoch(docs/staking.md§2): the entry budget as basis points of the active weight at the boundary, in place of the fixedmax_stake_entry_per_epoch(the two are refused together). Chain 18's 10 000 RAND holds an entrant to a third for two epochs of a 26 000-RAND set and for 1 301 of a 26 000 000-RAND one;2500holds it for two at either scale. Size it with the genesis stake (audit decision D24): a mainnet cut that changes the stake sizes must not carry chain 18's fixed figure.staking.slashing: { equivocation_bps, jail_epochs }(audit v6, STAKE-1;docs/staking.md§2, "Slashing leader equivocation"). A leader that signs two headers for one view losesequivocation_bpsof its bonded and unbonding stake and is jailed forjail_epochs(0 = for good, otherwise ≥ 2). Needsconsensus_domain: 1; refused besidevesting. Vote equivocation is not slashed. The build that carries it persistslast_proposed_viewbefore every proposal (a node-only change that rolls with this build): a leader that restarted mid-view could otherwise sign a second header and slash itself, so every validator must run this build before any chain with the section starts. Recommended once stake is held by more than one operator (D8) — with one operator's keys it changes nothing but the risk of a bug.testnet: true(top level;rand-node genesis --testnet). Required on any chain whose genesis keepsfaucet: truebeside abridgesection — free RAND beside real custody — on a chain id past 18 (GenesisError::FaucetWithBridgeNeedsTestnet; chains 14–18 are grandfathered by id,genesis::FAUCET_BESIDE_BRIDGE_CHAIN_IDS). Hashed under the tagtestnetonly whentrue; a mainnet genesis never carries it.rand_status.testnetandrand_getLimits.testnetserve it. The next testnet cut passes it; a mainnet cut passes neither it nor--faucet.staking.admission_by_vote: true(docs/staking.md§2, "Admission by vote"). ABondthat registers a new validator key is refusedNotAdmitteduntil validators holding strictly more than two thirds of the voting set's weight have signed it in (AdmitValidator, therand-node admit sign/admit submitcommands); a top-up needs no vote. The admitted set is consensus state (rand-state-admitted-1around the root,META_ADMITTED,rand_getAdmitted). Recommended for any chain that will hold value: until slashing exists it turns the price of two thirds of the stake into a vote. The cut passes it inside thestakingsection it splices in (or writes withrand-node genesis --staking STAKING.JSON), and every validator that will vote needs its key file at hand —admit signis offline. Every wallet that bonds should readrand_getLimits.admission_by_voteand say so before it proves a registering bond.