All pages

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:

RuleError text
1 to MAX_VALIDATORS (50) keysa validator set needs 1 to 50 validators
No key twiceduplicate validator key
No payout address twiceduplicate payout address
Every key is ML-DSA-65validators 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 has auth from genesis, and supplying one fails with RevealNotAllowed.
  • MAX_AUTH_KEYS (16) does not apply. That limit is in AuthPolicy::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::Recovery fails with NoRecoveryPolicy.
  • Signatures are ordinary transaction signatures under Domain::Transaction, by the validators' ML-DSA keys.
SenderSetValidators, DeployAny other payload
Governance accountAllowedNotAllowedForGovernance
Any other accountNotGovernanceAllowed

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:

  1. Check the signatures against the stored council, n - f of the current validators.
  2. Increment the nonce and debit the fee.
  3. Refuse the payload unless the sender is the governance account.
  4. Read the current set. Within a block, a change made earlier in that block counts as current.
  5. Build ValidatorSet { epoch: current.epoch + 1, validators } and run validate. Failure gives TxError::InvalidPolicy.
  6. Set the account's auth to next.council().
  7. Split this transaction's fee among the seats of the old set.
  8. Seat the new set for rewards: a payout address leaving keeps what it earned, and one joining starts from now.
  9. Write the new set under validators with 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 keyValue
consensus_epochThe current EpochInfo
consensus_epochsEvery EpochInfo after the first, in order
consensus_safetyThe 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
  1. E is proposed and verified like any block. In ChainApp, its speculative record is marked reconfigures.
  2. While E is uncommitted, every block extending it belongs to the reconfiguration suffix. propose puts no transactions in such a block, and verify refuses one that has any. has_work returns true, so the suffix keeps being built on an otherwise idle chain until E commits.
  3. The engine commits E, possibly with suffix blocks in the same App::commit call. ChainApp::commit stores blocks up to E and no further.
  4. E's proof goes through the suffix: FinalityProof { tip: E, path, qc }, where path holds the suffix headers from that call and the proof's own path. It verifies against the old set and the old chain.
  5. write_committed puts the block, its transaction index, consensus_tip, the tip proof, finality/<height>, consensus_epoch and consensus_epochs in one synced batch.
  6. ChainApp records epoch_end. Any further commit call returns at once.
  7. After the engine call that caused this, Validator::after_engine_call retires 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:

  • Config with chain: epoch.start and the index of this key in the new set.
  • Start::genesis(epoch.start) with committed_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 ChainApp from the tip, a new Assembler for n validators.

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 messageWhere it goes
The current epochThe current engine, if the sender is in the current set
The retired epochThe retired engine, if the sender is in that set
Anything elseDropped

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::retire sets retired and clears the speculative blocks.
  • verify returns false, so no new block enters. commit returns at once. has_work is false. propose returns an empty payload with zero roots, which others refuse.
  • The engine still gets messages tagged with its epoch and tick calls, and its Send and Broadcast outputs go out tagged with its epoch. It answers block requests from the blocks it holds and replies to timeouts with Sync.
  • act(outputs, false) drops PersistSafety and PersistBlock and 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:

  1. If this key is in the current set, call Validator::start, attach the follower with Validator::follow, and run as a validator from then on.
  2. Otherwise take one Follower::step over HTTP. Every block is verified and re-executed.
  3. 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 --peer before the change. TcpTransport drops 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 --peer address. KEY in --peer KEY=ADDR is a hex key or an index into the genesis validators, 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 removalBehaviour
With --followkeep_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 --followThe head stays at the block that ended the epoch
Restart with --followRuns as a standby
Restart without --followRefuses 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.

EventEffect on the payout address
In the genesis setbaseline 0: it earns from the first fee
Joinsbaseline = per_validator, so fees from before are not its own. owed is kept if it had a seat before
Leavesowed gains what it earned, and it stops earning. It can claim whenever it likes
Same payout, new keyNothing changes: the seat goes on
ClaimsReceives 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:

BeforeAfter the fee
The first validator100110
Each new validator010

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::voters is 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.
  • carry is 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.

  1. 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 key in validators is the same hex that qubits key show prints.

  2. Fund the governance account if needed, with qubits transfer.

  3. Write the proposal. --key reads a public key from a key file, and --public-key takes hex or @path. Keys are taken in that order, --key values first, and the result is the leader order of the new set. Give one --payout for 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.json
    

    This reads the account's nonce and writes a PartialTransaction valid for 7 days. Its fee covers threshold ML-DSA signatures. The command refuses to overwrite a file. Because the nonce is fixed here, another governance transaction landing first makes this one fail with BadNonce.

  4. 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.json
    

    Only keys in the council can sign, and a key signs once. tx show prints how many signatures the file has and needs.

  5. Anyone submits it once enough have signed. With too few, the command fails before reaching the node.

    qubits tx submit --file change.json --wait
    
  6. Check the result. epoch rises only after the block commits.

    qubits validators show
    curl http://127.0.0.1:8545/v1/epochs
    

Tests

TestWhat it shows
validators_replace_one_of_their_own_and_the_chain_carries_onA 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_leftA restart inside an epoch, then another change
two_thirds_of_validators_change_the_setToo few signatures are refused, a removed validator's signature is refused, auth follows the set
only_governance_changes_validators_and_it_does_nothing_elseNotGovernance, NotAllowedForGovernance
invalid_validator_sets_are_refusedEmpty, duplicate key, duplicate payout, SLH-DSA and 51-key sets
governance_pays_fees_like_any_accountInsufficientBalance
genesis_requires_ml_dsa_validatorsThe same rule at genesis
a_new_validator_earns_only_from_the_day_it_joinsThe example above, in real transactions
a_validator_that_leaves_keeps_what_it_earnedLeaving, claiming later, coming back
the_books_balance_whatever_happens300 random transactions and set changes: after each, the pool holds what is owed and the supply is unchanged
validators_change_the_set_through_the_cliThe 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.rs
  • chain/crates/qubits-types/src/transaction.rs
  • chain/crates/qubits-types/src/lib.rs
  • chain/crates/qubits-types/src/client.rs
  • chain/crates/qubits-execution/src/lib.rs
  • chain/crates/qubits-execution/src/rewards.rs
  • chain/crates/qubits-execution/tests/rewards.rs
  • chain/crates/qubits-node/src/app.rs
  • chain/crates/qubits-node/src/validator.rs
  • chain/crates/qubits-node/src/network.rs
  • chain/crates/qubits-node/src/sync.rs
  • chain/crates/qubits-node/src/main.rs
  • chain/crates/qubits-cli/src/main.rs
  • chain/crates/qubits-node/tests/governance.rs
  • chain/crates/qubits-execution/tests/execution.rs
  • chain/crates/qubits-cli/tests/chain.rs