Use this page to know when a transaction is final, how many validators must be up, and what the block fields you see in the RPC and the explorer mean. Consensus is chained HotStuff BFT with stake-weighted quorums; on chain 21, as on chain 20, that is 18 of 26 validators.
The rules the node runs
| rule | as implemented |
|---|---|
| Quorum | a quorum certificate (QC) is a set of Dilithium2 votes from validators holding strictly more than 2/3 of total stake |
| Leader schedule | pure round robin: the leader of view v is validators[v mod n], the set sorted by address. Stake weights votes, not leader selection |
| Proposing | the leader of view v proposes once it holds a QC for v − 1, or a quorum of NewView messages for v. A proposal that would apply an invalid transaction drops that transaction rather than the block |
| Voting and safety | a validator votes once per view, and only for a block that is safe with respect to its lock (its justify QC is newer than the locked QC, or the block extends the locked block). Safety state is fsynced to RocksDB before a vote leaves the node |
| Signing domain | on a genesis with consensus_domain: 1 (chain 21, and every chain since 15) every vote, new-view and proposal signature covers the genesis hash (rand-vote-2, rand-newview-2, rand-block-2), so a signature for one chain verifies on no other. Chain 14 and earlier signed without it. Since chain 20 binding_domain: 1 extends the same rule to transaction proofs and signed actions (Transactions) |
| Sibling proposals | a replica holds one block per (view, leader); a second, different block from that leader for that view is refused as equivocation. A proposal more than 8 views past the replica’s own (PROPOSAL_VIEW_WINDOW) is refused before its signature is checked; a full speculative tree evicts off-branch blocks before it refuses anything |
| Lock release | the lock is never lowered automatically (CON-4, v0.6.8). It yields only to a proposal whose justify QC outranks it, or to the locked block arriving. A quorum of signed NotHeld answers is logged for the operator and releases nothing, and a validator records its votes so it never signs NotHeld for a block it voted for. Every certified block above the head is persisted and restored at restart, so a whole-fleet restart does not strand a QC on a block nobody holds. A validator whose locked block is lost for good is released by hand, offline: rand-node safety release-lock |
| QC assembly | every validator assembles QCs locally from gossiped votes, so the chain keeps committing when the next leader is offline |
| Commit | the chained three-QC rule: a block commits once three certificates chain on top of it in consecutive views v, v+1, v+2 and that chain threads back to the committed head |
| Timeouts and view changes | base --view-timeout-ms 3,000 ms, doubling per consecutive timeout, reset on any progress. A timeout-certificate pacemaker (CH-1, v0.6.8): a replica whose timer fires stays in its view and signs a NewView asking for the next. It enters a view on a QC for the view before, or when validators holding more than 2/3 of the stake have asked for that view or a later one; it joins (signs its own ask) when more than 1/3 ask for a view past its own. No single validator’s NewView moves anyone. Views are bounded (MAX_VIEW_AHEAD = 10⁶) |
| Pacing | the leader proposes an empty block when nothing is pending, at least --block-interval-ms (default 1,000 ms) after the last block seen from any proposer |
| Vote and gossip bounds | votes more than 8 views ahead are ignored and one vote counts per (view, voter); consensus gossip is metered per forwarding peer (burst 256, 64/s); a proposal whose parent is unknown is kept as an orphan only if its proposer leads its view in the current epoch’s set or the next one’s, it is within the tree bound of the committed head, and its size and tx root check out (orphan pool capped at 64 MiB, and since v0.6.1 at 16 orphans per proposer). Since v0.6.8 a proposal is relayed, and an orphan kept, only if its transaction list hashes to the root its header commits to with no transaction repeated (GOSSIP-2) |
| Block limits | the chain’s max_block_bytes of transaction bytes (4 MiB by default; 19 MiB, 19,922,944 bytes, in chain 21’s genesis; 20 MiB in chain 20’s) and 2,000 transactions, enforced by the proposer and again by every replica in Ledger::apply_block |
| Compact blocks (v0.8.0, node-only) | a leader gossips CompactProposal (tag 4 on the consensus topic): the header, its signature and the transaction hashes, 166,416 bytes at 26 validators and 2,000 hashes, instead of the full block. A replica pre-screens the header, rebuilds the block from its mempool and a cache of recently seen transactions, and fetches only the bodies it lacks by hash (SyncRequest::Transactions, 512 a request; rand_status.compact_fetched counts them). An older node cannot decode the frame, so the change rolled with the chain-21 cut; a mixed validator set would stop committing |
| Off-loop apply (v0.8.0, node-only) | a follower applies an incoming proposal on a blocking thread (at most 4 at once, up to 8 waiting, at most 2 per proposer) while the prechecks and the insert stay on the consensus loop; a leader’s second block for a view waits behind the first and is refused as an equivocation once the first is in. rand-node run --apply-proposals-inline restores the inline apply |
| Block time | on a chain with a bridge section: a block’s timestamp_ms may not precede its parent’s (TimestampRewind) nor exceed it by more than 60 s (TimestampLeap), and a validator withholds its vote from a block more than 15 s ahead of its own clock; see below |
The three-consecutive-view commit rule is load-bearing: relaxing it re-opens a conflicting-finality bug the fullnode has a regression test for. Everything a client sees through the RPC is committed: only committed blocks are served, and the WebSocket newHeads stream sends one notification per committed block, in order.
The table is v0.8.0 (c0878c3f), the release chain 21 runs since 2026-10-12; the two v0.8.0 rows are node-only changes that alter no vote and no commit rule, and the rest is what v0.6.8 brought to chain 20. Three of its rules are new in v0.6.8 (audit v6) and are live since chain 20 went live on 2026-10-01: the timeout-certificate pacemaker (CH-1), no automatic lock release (CON-4) and the proposal transaction-list check (GOSSIP-2). On chains 18 and 19 (v0.6.7) a single validator’s signed NewView moved every replica to its view, and the lock was lowered on signed NotHeld answers from a quorum of the stake. The pacemaker and the lock rule change what validators sign and when, so v0.6.8 rolled all-stop/all-start with the chain 20 cut, never node by node.
The testnet’s numbers
Chain 21 has 26 validators, each staked at exactly 1,000 RAND, all 26 in its genesis on the keys and peer ids chains 17 to 20 ran. On chain 15 eighteen were in the genesis, on chain 14’s keys, and eight more bonded on 2026-09-27 under v2 registrations and were active from epoch 43. Stake is uniform, so quorum is a headcount: 18 of the 26 must be online to commit anything. With 17 up, views advance and nothing finalises. A set of n equal validators needs more than 2/3 of stake: 2 of 2, 3 of 4, 5 of 6, 13 of 18, 18 of 26. The genesis weight cap (3,333 bps per validator) does not bind a set of equal stakes.
Blocks are paced at the default 1,000 ms. On chain 15 they arrived about every 1.2 s in practice (1.18 s averaged over its first 29 hours); on chain 20 the explorer reported an average of 1.34 s at height 3,270 (2026-10-01, avg_block_time_ms), and on chain 21 1.17 s at height 2,283 (2026-10-12). The explorer reports the average block time it observes (avg_block_time_ms in GET /stats). A validator’s view runs ahead of the height whenever views time out, which is why rand_getHead reports both.
Epochs
The validator set is re-derived from the register every epoch_blocks = 1,000 blocks (a genesis parameter). epoch(h) = h / 1000; the set for epoch e ≥ 1 is derived from the register as of the last block of epoch e − 1: every row with stake ≥ 1,000 RAND, the top 100 by stake, sorted by address. On a chain without a staking section a bond becomes weight at the next boundary. On chain 20 it waits two more epochs and a per-epoch entry budget of 2,500 bps of the active bonded weight at the boundary (6,500 RAND while the set holds 26,000), and a bond that registers a new validator is refused unless the set has first voted the key in with AdmitValidator (staking.admission_by_vote). Chain 19’s budget was a fixed 10,000 RAND an epoch, and it had no admission vote. An unbond costs weight at the next boundary, while the amount waits two epochs to become withdrawable. rand_getEpoch returns the epoch, the epoch length and a projection of the next set. See Staking.
What a block carries
{
"hash": "647b…", "height": 50, "view": 92, "parent": "2d41…",
"proposer": "3v3VBJ…", "timestamp_ms": 1788000123456,
"tx_root": "0000…", "state_root": "a1b2…", "justify_view": 91,
"tx_count": 0, "transactions": []
}
view is the view the block was proposed in and justify_view the view of the QC it carries for its parent. timestamp_ms is set by the proposer, within the bounds of the next section. state_root commits the ledger after the block. On chain 21 (chain 20’s fields, with the perps and multisig state folded in as below):
inner = blake3("rand-state-8" || tree_root || nullifier_root || validators_root || programs_root
|| bridge_root || tokens_root
|| faucet_epoch || faucet_minted_in_epoch || bond_queue_root
|| gas_price || byte_price
|| program_state_root)
state_root = blake3("rand-state-admitted-1" || inner || admitted_root)
Chain 19 committed the same fields without program_state_root, under rand-state-7, with no wrapper.
The components depend on the genesis. A chain with none of the optional sections commits the first four under rand-state-2. A bridge section appends bridge_root. A tokens section appends tokens_root after it and re-domains the whole hash rand-state-4; a genesis with a bridge section must have a tokens section, so chain 21, like chain 20, has both. An aggregation section appends an aggregators root next (under rand-state-3 on a chain without tokens); chain 21 has none. A staking section appends the faucet’s two epoch counters and the bond queue’s root (rand-bond-queue-1) last and re-domains the whole hash rand-state-5, whatever else is on; chain 14 had no such section and hashed under rand-state-4, and chains 15 to 17 hashed under rand-state-5. A vesting section appends the vesting register’s root after that and re-domains the hash rand-state-6; chain 21 has none. A gas section with a dynamic controller appends the live gas_price and byte_price, each a big-endian u64, after everything else and re-domains the hash rand-state-7. That was chain 19’s domain, and chains 20 and 21 keep the prices in their root: they move once per block, after the block’s transactions and before the root (Fees). A gas section with fixed prices is not in the root. A program_state section (RPL-2) appends the root of every program’s cells and vaults after the prices and re-domains the hash rand-state-8: chain 20’s. A perps section (RPL-3, v0.8.0) appends the exchange’s state after that and re-domains the hash rand-state-9: chain 21’s; its multisig section wraps the register of multisig accounts in once more under rand-state-multisig-1 (and a fees.usd_subsidy section, which chain 21 lacks, would add the voted price under rand-state-price-1). Audit v6’s staking state is folded in around that rather than inside it: under staking.admission_by_vote (chains 20 and 21) the root is blake3("rand-state-admitted-1" || inner || admitted_root), and under staking.slashing (no chain yet) a second wrapper adds the jailed set. tokens_root is the Merkle root of blake3("rand-token-leaf-1" || bincode(TokenInfo)) leaves in index order, so every token’s metadata, authority, mint_nonce, supply and each backing’s locked amount is consensus state: two nodes that disagree about one token row disagree about the chain. On chain 20 the token root also folds in the registry extension (the max_tokens cap, the burned registration fee, the note-value bound and the bridge’s rolling mint-cap windows) under rand-token-registry-3. The bridge root covers the emitters, the guardian sets, the spent attestation digests, the burn sequence, the PQ guardian keys, the pause key, the pause flag and the two governance nonces; the asset registry moved out of it into tokens_root. Chain 20’s bridge has rules_v2, so the rotation nonce and the global cap parameters are appended and the domain is rand-bridge-state-5 (chain 14’s was rand-bridge-state-4); that root is then wrapped by the inbound replay floor (rand-bridge-replay-floor-1), and on chain 20 also by the rotation rules and pending rotations (rand-bridge-rotation-1) and the fee group (rand-bridge-fees-1). The validator register is hashed into validators_root (leaf domain rand-validator-leaf-4 on chain 20, with each row’s activation_epoch; rand-validator-leaf-2 on a chain without a staking section), so a node that disagrees about one register row disagrees about the chain.
Block time is bounded (B2)
On a bridged chain the block timestamp decides guardian-set expiry and the day a backing’s daily mint cap counts against, so a leader who could jump the clock forward could expire a rotated set’s grace window early or skip past a day’s cap. Chain 21, like chain 20, has a bridge section, so three rules apply, all gated on it (and a perps section bounds block time the same way):
| rule | bound | kind |
|---|---|---|
| no rewind | timestamp_ms ≥ parent.timestamp_ms (equal is allowed), else BlockError::TimestampRewind; the proposer emits max(now, parent.timestamp_ms) | validity, checked on replay and sync too |
| no leap | timestamp_ms ≤ parent.timestamp_ms + MAX_TIMESTAMP_STEP_MS (60,000 ms), else BlockError::TimestampLeap | validity, checked on replay and sync too |
| clock drift | a validator refuses to vote for a block more than MAX_CLOCK_DRIFT_MS (15,000 ms) ahead of its own clock | a vote rule only: the block stays in the speculative tree, a quorum whose clocks agree can still certify it, and replaying committed history never applies it |
Every validator on a bridged chain needs NTP. A validator whose clock is more than 15 seconds off is a faulty leader (its proposals may get no votes) and a faulty voter (it may refuse an honest leader’s on-time block). Neither halts the chain while the honest majority’s clocks agree, but an unsynchronised fleet loses liveness margin for nothing.
The genesis timestamp_ms has to be close to the real launch time. Too far in the past and block time lags wall clock until the step bound lets it catch up, at most 60 s per block. More than 15 s in the future and block 1 does not commit at all: every vote, the leader’s own included, is withheld until wall time reaches it. After a long halt the same catch-up can roll a mint-cap day over faster than once per real day, bounded to one extra day’s cap per full day of accumulated lag.
Finality for a client
- A transaction is final when
rand_getTransaction(hash)returns a block. Acceptance byrand_sendTransactionis only admission to the mempool. - A block served by the RPC or indexed by the explorer is committed. There is no finality window to wait out; the explorer’s guide says the same.
- A head from
rand_subscribe["newHeads"]is a head the node will not lose: nothing is sent before the block is committed to storage. - A node that falls behind re-verifies everything: every certificate, every bundle and call proof, every receipt, before it accepts a synced block. Finality signatures are never trusted in place of proofs.
Recovery cheatsheet, from the fullnode’s own: height not advancing with a low peer_count is a networking problem; height not advancing with peers fine means fewer than 2/3 of stake is online; CORRUPT CHAIN at startup means the node truncated a damaged tail and is resyncing.