Validators and epochs
The validator set is chain state, and it changes only through a transaction signed by a quorum of the current validators. This page specifies the governance account, the SetValidators rules, how an epoch ends and the next begins, what a node keeps across the boundary, and how keys join and leave. It is for node operators and auditors. The engine itself is on Consensus and peer connections on Validator networking.
The validator set
pub struct Validator {
pub key: PublicKey,
pub payout: Address,
}
pub struct ValidatorSet {
pub epoch: u64,
pub validators: Vec<Validator>,
}
The set lives in state under the key validators, borsh-encoded. Genesis writes it with epoch: 0. key signs blocks and payout is where the validator's share of the fees goes. See Rewards. The order of validators is the order of ValidatorIndex and of the leader rotation's fallback, so it is part of the set: qubits validators propose takes keys "in leader order".
ValidatorSet::validate is what both Genesis::validate and SetValidators apply:
| Rule | Error text |
|---|---|
1 to MAX_VALIDATORS (50) keys | a validator set needs 1 to 50 validators |
| No key twice | duplicate validator key |
| No payout address twice | duplicate payout address |
| Every key is ML-DSA-65 | validators must use ML-DSA-65 keys |
max_faulty = (len - 1) / 3 integer division
quorum = len - max_faulty
The consensus crate has its own ValidatorSet, which holds only the keys. The node builds it from the state value when an epoch opens.
Why 50
MAX_VALIDATORS is 50 so that a quorum of ML-DSA signatures fits in one transaction. The largest change names 50 keys and carries the 34 signatures of the quorum of the set before it. Encoded, that transaction is 210,667 bytes, under MAX_TX_BYTES of 294,912 bytes (288 KiB). The test largest_validator_change_fits_the_limit pins this and checks that the transaction exceeds 192 KiB, the limit before it.
The governance account
pub fn governance_address() -> Address {
Address(crate::hash::Hash::digest(crate::hash::context::GOVERNANCE, &[]).0)
}
The address is the BLAKE3 derive-key hash of empty input under the context qubits.sh 2026-11 validator governance. It is not the hash of a policy, as other addresses are, and it is the same on every chain:
qb157cmgrg86jcm5t46njx2l85ly6tx3udv84mwm4gqjsl92hmqguzszhfztx
Its owner policy is always a quorum of the current validators:
pub fn council(&self) -> AuthPolicy {
AuthPolicy {
threshold: self.quorum() as u8,
keys: self.keys.clone(),
}
}
genesis_writes sets auth to the council of the genesis set, and every SetValidators replaces it with the council of the new set in the same transaction. Consequences:
- A governance transaction carries no
reveal. The account hasauthfrom genesis, and supplying one fails withRevealNotAllowed. MAX_AUTH_KEYS(16) does not apply. That limit is inAuthPolicy::validate, which runs on a policy a sender reveals, and the council is written by execution. A council can hold 50 keys.- It has no vault and no recovery policy, so
Role::Recoveryfails withNoRecoveryPolicy. - Signatures are ordinary transaction signatures under
Domain::Transaction, by the validators' ML-DSA keys.
| Sender | SetValidators, Deploy | Any other payload |
|---|---|---|
| Governance account | Allowed | NotAllowedForGovernance |
| Any other account | NotGovernance | Allowed |
The account pays fees like any account, so it needs funds, from a genesis accounts entry or a plain transfer. Without them a change fails with InsufficientBalance. Contract deployment goes through the same account; see Contracts.
SetValidators
Payload::SetValidators {
validators: Vec<Validator>,
}
After the checks every transaction passes (see Transactions), execution does the following:
- Check the signatures against the stored council,
n - fof the current validators. - Increment the nonce and debit the fee.
- Refuse the payload unless the sender is the governance account.
- Read the current set. Within a block, a change made earlier in that block counts as current.
- Build
ValidatorSet { epoch: current.epoch + 1, validators }and runvalidate. Failure givesTxError::InvalidPolicy. - Set the account's
authtonext.council(). - Split this transaction's fee among the seats of the old set.
- Seat the new set for rewards: a payout address leaving keeps what it earned, and one joining starts from now.
- Write the new set under
validatorswith the block's other writes.
The change has no gas limit, so its minimum fee is min_fee_per_byte times its size.
Nothing requires overlap between the old and new sets. Nothing checks that the new keys are online either: if fewer than a quorum of the new set run, the chain halts at the start of the epoch, and the old set can no longer act. A change that keeps the same keys is valid and starts a new epoch.
A block changes the set at most once. A second SetValidators in the same block, which the council the first one installed could sign, fails with TxError::ValidatorsChanging, so epochs always rise by one, as epoch_views requires of the chain joining nodes walk. A proposer leaves it in the mempool, and it is valid once the new epoch has begun (the_validator_set_changes_once_a_block).
Epochs
An epoch is the stretch of chain run by one validator set. It is described by:
pub struct EpochInfo {
pub epoch: u64,
pub start: Hash,
pub start_height: u64,
}
start is the id of the block that contains the change, or the genesis hash for epoch 0, and start_height is that block's height. start is the chain value of the epoch's engine and therefore part of everything its validators sign, so signatures cannot be replayed across epochs. The test validators_replace_one_of_their_own_and_the_chain_carries_on checks that a certificate of the new epoch verifies against the new set and start, and not against the old set and the genesis hash.
| Metadata key | Value |
|---|---|
consensus_epoch | The current EpochInfo |
consensus_epochs | Every EpochInfo after the first, in order |
consensus_safety | The SafetyRecord |
finality/<height> | Kept proofs, always including the block that ends an epoch |
How an epoch ends
The block E containing the change ends the epoch. The next epoch cannot start until E is committed, and committing needs blocks on top of it.
old epoch ... - B - E - S1 - S2 S1, S2: the suffix, empty, never stored
|
new epoch + - N N: height of E + 1, parent E, round 1 or later
Eis proposed and verified like any block. InChainApp, its speculative record is markedreconfigures.- While
Eis uncommitted, every block extending it belongs to the reconfiguration suffix.proposeputs no transactions in such a block, andverifyrefuses one that has any.has_workreturns true, so the suffix keeps being built on an otherwise idle chain untilEcommits. - The engine commits
E, possibly with suffix blocks in the sameApp::commitcall.ChainApp::commitstores blocks up toEand no further. E's proof goes through the suffix:FinalityProof { tip: E, path, qc }, wherepathholds the suffix headers from that call and the proof's own path. It verifies against the old set and the oldchain.write_committedputs the block, its transaction index,consensus_tip, the tip proof,finality/<height>,consensus_epochandconsensus_epochsin one synced batch.ChainApprecordsepoch_end. Any furthercommitcall returns at once.- After the engine call that caused this,
Validator::after_engine_callretires the engine and opens the next epoch.
Every validator ends the epoch at the same block, because E is a block of the chain and everything after it is empty and dropped. Transactions still in the mempool wait for the next epoch.
How an epoch starts
Validator::open_epoch reads EpochInfo and the validator set from the store. If this key is not in the set it stops there. Otherwise it builds:
Configwithchain: epoch.startand the index of this key in the new set.Start::genesis(epoch.start)withcommitted_height: epoch.start_height, so the epoch runs like a chain from genesis: a certificate of round 0 with no votes, round 1 first, no leader history.- A new
ChainAppfrom the tip, a newAssemblerfornvalidators.
A validator restarting in the middle of an epoch takes the other branch: committed, high_qc and the reputation window come from consensus_tip and the stored blocks, limited to blocks above start_height.
Messages across the boundary
Consensus traffic is tagged with the sender's epoch:
pub enum NetMessage {
Consensus { epoch: u64, message: Message<BlockPayload> },
Transaction(SignedTransaction),
Shard { epoch: u64, shard: Shard },
}
epoch in the message | Where it goes |
|---|---|
| The current epoch | The current engine, if the sender is in the current set |
| The retired epoch | The retired engine, if the sender is in that set |
| Anything else | Dropped |
Messages for a later epoch are not buffered. Indices differ between epochs, so the node maps a sender's public key to an index per epoch. See Validator networking.
Retired engines
After an epoch ends, Validator keeps the old engine for RETIRED_FOR_MS (60 s) to help validators that have not committed E yet. It holds one retired engine at a time, so a second change within 60 s replaces it.
ChainApp::retiresetsretiredand clears the speculative blocks.verifyreturns false, so no new block enters.commitreturns at once.has_workis false.proposereturns an empty payload with zero roots, which others refuse.- The engine still gets messages tagged with its epoch and
tickcalls, and itsSendandBroadcastoutputs go out tagged with its epoch. It answers block requests from the blocks it holds and replies to timeouts withSync. act(outputs, false)dropsPersistSafetyandPersistBlockand leaves pending blocks alone, so nothing it does reaches the disk.
A retired engine can still vote for a block it verified before retiring, because such a block stays in arrived and normal proposals for known blocks are still handled. Those votes concern suffix blocks of the old epoch, which are never stored.
After 60 s no peer answers for that epoch. A validator that missed E for longer can catch up only over HTTP, so it needs --follow. See Sync.
What is kept per epoch
struct SafetyRecord {
epoch: u64,
state: SafetyState,
}
The safety state is stored with its epoch under consensus_safety. open_epoch uses it only if record.epoch equals the current epoch, and otherwise starts from SafetyState::default(). Rounds restart at 1 in a new epoch, and a vote there is a signature over a different chain, so the old rounds impose nothing. The test validator_restarts_into_the_epoch_it_left restarts a validator inside epoch 1 and then takes it to epoch 2.
Pending blocks are keyed by epoch, height, both 8-byte big-endian, then id. open_epoch hands back only those with the current epoch's prefix. Committing deletes every key below the committed height's, which also clears earlier epochs.
Blocks that end an epoch keep their proofs forever, whatever --history-keep says, so a joining node can walk from the genesis validators to the current set. See Sync.
Joining the set
A key that is not in the set runs as a standby, which needs --follow, and a --peer for every current validator:
qubits-node run --data-dir data --validator-key v4.json --password-file pw \
--p2p-addr 0.0.0.0:9545 --peer 0=10.0.0.1:9545 --peer 1=10.0.0.2:9545 \
--follow http://10.0.0.1:8545
Without --follow, the node refuses to start and says the key is not in the current validator set. With it, main opens peer connections and calls spawn_standby, which loops:
- If this key is in the current set, call
Validator::start, attach the follower withValidator::follow, and run as a validator from then on. - Otherwise take one
Follower::stepover HTTP. Every block is verified and re-executed. - Discard anything that arrived on the peer inbox, and sleep 100 ms.
The follower carries the node across the boundary itself: a block that changes validators needs its own finality proof, because the next block is signed by others.
Peers are identified by public key, and the peer list is fixed when the process starts. Two operational rules follow:
- Current validators must list future validators in
--peerbefore the change.TcpTransportdrops messages to a key it has no entry for, and the handshake refuses a key outside the list. Changing the list takes a restart. - At startup a node refuses to run if a current validator has no
--peeraddress.KEYin--peer KEY=ADDRis a hex key or an index into the genesisvalidators, never into the current set.
Leaving the set
When a validator commits the change that removes it, open_epoch finds no index. current stays empty, consensus stops, and the node logs this key is no longer a validator; consensus stopped. The HTTP API stays up. The retired engine runs out its 60 s.
| After removal | Behaviour |
|---|---|
With --follow | keep_up syncs whenever the node is behind. If a later change adds the key back, open_epoch returns outputs and the node validates again |
Without --follow | The head stays at the block that ended the epoch |
Restart with --follow | Runs as a standby |
Restart without --follow | Refuses to start |
Other validators ignore its consensus messages, since it is not in the set. Its connections stay open until they restart with a new peer list.
Rewards
Fees pay the validators. Each fee goes to the pool at rewards_address(), an address no key leads to, and every seat in the validator set earns an equal share. The coins stay in the pool until someone claims them for a payout address.
pub struct RewardTotals { // state key `rewards`
pub per_validator: Amount, // what one seat has earned since genesis
pub carry: Amount, // what didn't divide evenly
}
pub struct RewardAccount { // state key `reward/` then the payout address
pub baseline: Amount, // per_validator when it started earning
pub owed: Amount, // earned in earlier seats, not claimed yet
}
A fee moves one number, so it costs the same with 1 validator or 50. After a transaction, with fee what it paid net of any gas refund:
due = carry + fee
per_validator = per_validator + due / seats integer division
carry = due % seats
What a payout address can claim is owed, plus per_validator - baseline while it has a seat.
| Event | Effect on the payout address |
|---|---|
| In the genesis set | baseline 0: it earns from the first fee |
| Joins | baseline = per_validator, so fees from before are not its own. owed is kept if it had a seat before |
| Leaves | owed gains what it earned, and it stops earning. It can claim whenever it likes |
| Same payout, new key | Nothing changes: the seat goes on |
| Claims | Receives everything it can claim, and owed goes to 0 |
The transaction that changes the set pays the old set, and a block's later transactions pay the new one. With 1 validator at 100 and then 5 validators sharing a fee of 50:
| Before | After the fee | |
|---|---|---|
| The first validator | 100 | 110 |
| Each new validator | 0 | 10 |
Claiming
Payload::ClaimRewards { payout: Address }
Any account can send this and pays its fee, so a new payout address needs no coins of its own. The coins can only go to payout. With nothing to claim the transaction fails with TxError::NothingToClaim and pays nothing. A vault can send it without waiting, because nothing leaves the vault except its fee.
qubits validators rewards # the pool and every validator
qubits validators rewards "$PAYOUT" # one address
qubits validators claim --payout "$PAYOUT" --key me.json --wait
The API has the same numbers at GET /v1/rewards.
Notes
- The validator key signs blocks on a server. A thief who takes it can't move the rewards, because only governance sets
payout. - Validators earn for holding a seat, not for proposing or voting.
Block::votersis not authenticated, and paying the proposer would let it fill blocks with its own transactions for free. A validator that stops still earns until governance removes it. - The total supply is fixed: fees change hands and no coin is made or burned.
- Coins sent to the pool's address on purpose count for nobody and stay there for good.
carryis below the number of seats when it is written, and can be above a smaller set's size after a change until the next fee.
Changing the set with the CLI
Governance transactions are multisig transaction files. --rpc or QUBITS_RPC selects the node, by default http://127.0.0.1:8545.
-
Look at the current set. The command prints
GET /v1/validators:qubits validators show{ "epoch": 0, "epoch_start": "fabaed48ca4ce5fadd5bfb84829428cdb7ae53eac4dc451a5228e8686c7ca72b", "epoch_start_height": 0, "validators": [{ "key": "01f3607e216def7e...", "payout": "qb1vealhtjg...se9d" }], "quorum": 1, "governance": "qb157cmgrg86jcm5t46njx2l85ly6tx3udv84mwm4gqjsl92hmqguzszhfztx" }This is a single-validator test chain, with the key shortened here. A
keyinvalidatorsis the same hex thatqubits key showprints. -
Fund the governance account if needed, with
qubits transfer. -
Write the proposal.
--keyreads a public key from a key file, and--public-keytakes hex or@path. Keys are taken in that order,--keyvalues first, and the result is the leader order of the new set. Give one--payoutfor each validator, in the same order.qubits validators propose --key v0.json --key v1.json --key v2.json --key v4.json \ --payout "$P0" --payout "$P1" --payout "$P2" --payout "$P4" --out change.jsonThis reads the account's nonce and writes a
PartialTransactionvalid for 7 days. Its fee coversthresholdML-DSA signatures. The command refuses to overwrite a file. Because the nonce is fixed here, another governance transaction landing first makes this one fail withBadNonce. -
Each signer adds a signature. This step needs no node, and the file can travel between machines.
qubits tx sign --file change.json --key v0.json --password-file pw qubits tx show --file change.jsonOnly keys in the council can sign, and a key signs once.
tx showprints how many signatures the file has and needs. -
Anyone submits it once enough have signed. With too few, the command fails before reaching the node.
qubits tx submit --file change.json --wait -
Check the result.
epochrises only after the block commits.qubits validators show curl http://127.0.0.1:8545/v1/epochs
Tests
| Test | What it shows |
|---|---|
validators_replace_one_of_their_own_and_the_chain_carries_on | A live swap with a standby: same boundary everywhere, new signatures, old signatures refused, joins through blocks, snapshots and pruned history |
validator_restarts_into_the_epoch_it_left | A restart inside an epoch, then another change |
two_thirds_of_validators_change_the_set | Too few signatures are refused, a removed validator's signature is refused, auth follows the set |
only_governance_changes_validators_and_it_does_nothing_else | NotGovernance, NotAllowedForGovernance |
invalid_validator_sets_are_refused | Empty, duplicate key, duplicate payout, SLH-DSA and 51-key sets |
governance_pays_fees_like_any_account | InsufficientBalance |
genesis_requires_ml_dsa_validators | The same rule at genesis |
a_new_validator_earns_only_from_the_day_it_joins | The example above, in real transactions |
a_validator_that_leaves_keeps_what_it_earned | Leaving, claiming later, coming back |
the_books_balance_whatever_happens | 300 random transactions and set changes: after each, the pool holds what is owed and the supply is unchanged |
validators_change_the_set_through_the_cli | The whole CLI flow against a node |
The governance phase of chain/crates/qubits-e2e/e2e.py repeats the change with real processes, including a removed validator that keeps following.
Source
chain/crates/qubits-types/src/account.rschain/crates/qubits-types/src/transaction.rschain/crates/qubits-types/src/lib.rschain/crates/qubits-types/src/client.rschain/crates/qubits-execution/src/lib.rschain/crates/qubits-execution/src/rewards.rschain/crates/qubits-execution/tests/rewards.rschain/crates/qubits-node/src/app.rschain/crates/qubits-node/src/validator.rschain/crates/qubits-node/src/network.rschain/crates/qubits-node/src/sync.rschain/crates/qubits-node/src/main.rschain/crates/qubits-cli/src/main.rschain/crates/qubits-node/tests/governance.rschain/crates/qubits-execution/tests/execution.rschain/crates/qubits-cli/tests/chain.rs