Chain
PBFT consensus
Three-phase PBFT where each vote is an Ed25519 signature bound to its kind and its chain, a quorum is the smallest count any two of which share an honest validator, and every engine the node builds verifies every signature.
What PBFT consensus is
PBFT is the agreement protocol a UniNet chain uses to decide, among a fixed set of validators, which block occupies which sequence number. One validator per view is the leader and proposes; the rest vote in two rounds; a block is final when a quorum has voted to commit it. There is no fork choice and no probabilistic settlement: a sequence number holds one block or none.
The problem it addresses is that a set of machines which do not trust each other must still produce one ordering. A leader-only design gives a single machine the power to decide history. A majority vote over unauthenticated messages gives that power to whoever can name the most identities. PBFT answers both by requiring two rounds of votes from more validators than can be faulty, and by making every vote a signature that names its signer.
The implementation is the uninet-consensus crate: pbft/state.rs holds the state machine, pbft/view_change.rs the leader-failure path and the certificates, pbft/messages.rs the wire types and signed payloads, quorum.rs the arithmetic. The engine is a state machine: it consumes messages and returns actions, and the host puts the resulting bytes on the network.
The three phases
A round begins when the leader for the current view broadcasts a PrePrepare carrying its proposed block. A replica accepts it only while idle, only from the validator that round-robin selection names as leader for that view, and only if the block hash matches the block bytes. Before it may vote, the node checks the proposal against its own chain (Chain::check_proposal): the block must sit on this node's tip at exactly the next height, carry no commit signatures, be encoded exactly as the bytes the votes will sign, and name the state root this node holds. A validator therefore votes only for a block it would apply as its next one. It then broadcasts a Prepare. When a validator has a quorum of Prepare messages naming the same (view, sequence, block_hash), the leader's own proposal counting as its vote, it moves to Prepared and broadcasts a Commit. When it has a quorum of Commit messages, the block is final.
Leader Replica 1 Replica 2 Replica 3
│ │ │ │
│── PrePrepare ──────▶│──────────────────▶│──────────────────▶│ leader for the view checked
│ │ │ │ block checked against own chain
│ │ │ │
│◀═══ Prepare ════════╪═══ all-to-all ════╪═══════════════════╡ quorum → Prepared
│ │ │ │
│◀═══ Commit ═════════╪═══ all-to-all ════╪═══════════════════╡ quorum → Committed
│ │ │ │
▼ ▼ ▼ ▼
FinalizeBlock FinalizeBlock FinalizeBlock FinalizeBlock
sequence += 1 sequence += 1 sequence += 1 sequence += 1
Finalization emits a FinalizeBlock action carrying the block bytes and a commit certificate: the (validator, signature) pair from every Commit counted. The node staples that certificate into the block header before applying it, which is what lets a peer that syncs the block later verify it without having witnessed the round.
A commit quorum finalizes whatever state the node is in. A validator whose timer fired before the Commits arrived, one that never received the PrePrepare, or one whose own Commit completed the quorum still finalizes; if it never had the block bytes, the block reaches it by block sync. The engine holds one round in flight: a proposal arriving while it is not idle is refused.
| State | Entered when | What moves it on |
|---|---|---|
Idle | at construction, after finalizing, after adopting a new view | the leader proposing, or an accepted PrePrepare |
PrePrepared | this node proposed, or accepted a PrePrepare | a prepare quorum, or a commit quorum directly |
Prepared | a quorum of matching Prepare messages arrived | a commit quorum |
Committed | a quorum of matching Commit messages arrived | set and left within the same call: the sequence advances and the state returns to Idle |
ViewChanging | this node initiated a view change: its timeout fired, or its operator asked | a view-change quorum, or a valid NewView |
Driving the engine
The engine never touches the network. The node's network manager feeds every inbound consensus message to handle_message_ex, which returns the messages to broadcast and, when a commit quorum completes, the finalized block. The manager reacts the moment a message arrives or something is queued to send, rather than on a timer, so a round moves at the speed of the network: on four nodes on one machine a transfer is final on every node about 60 ms after it arrives, and on the hosted testnet, five validators on rented servers and a home connection, every server has it final a median of 75 ms after it arrives (see what runs today). A 50 ms tick drives what needs a clock: the view-change timer, through note_pending_work and tick_view_change_ex, and the node's periodic gossip.
A leader that proposes and then goes silent is caught by the next message any peer sends, since every inbound message also drives the view-change clock. A leader that proposes nothing at all generates no traffic, which is why the node calls note_pending_work when its mempool holds work: that arms the timer if the engine is idle and no timeout is running, so a chain with nothing to order does not rotate leaders for lack of anything to do.
Per-round state lives in a message log keyed by (view, sequence, node_id, msg_type), which enforces one vote per validator per round: a repeat is dropped before it is counted. Checkpoints collect the log (see below), so a long-running validator's memory stays bounded.
Quorum arithmetic
quorum.rs is the whole of it. max_faulty(n) = (n - 1) / 3 in integer division, and quorum_threshold(n) = ceil((n + f + 1) / 2): the smallest count for which any two quorums share at least f + 1 validators, so at least one honest validator sits in both and two conflicting blocks cannot both reach a quorum. The prepare tally, the commit tally, the view-change tally, the checkpoint tally and the distinct-signer count inside a certificate all compare against that one threshold.
| Validators (n) | Faults tolerated (f) | Quorum | Note |
|---|---|---|---|
| 4 | 1 | 3 | smallest set with f ≥ 1 |
| 5 | 1 | 4 | MIN_CHAIN_NODES, the default minimum |
| 7 | 2 | 5 | |
| 12 | 3 | 8 | MIN_FOUNDATIONAL_NODES |
| 21 | 6 | 14 |
At n = 3f + 1 (4, 7, 10, 13 and so on) this is the textbook 2f + 1; at every other size it is larger. The textbook value would be unsafe there: at five validators 2f + 1 is 3, and two groups of three out of five share only one validator, who could be the faulty one. Sizes of the form 3f + 1 are the efficient ones: five validators tolerate one fault, as four do, but need four votes. A test walks every n and asserts that two quorums always intersect in at least f + 1 validators and that a quorum is reachable from the honest validators alone.
PbftConfig::min_validators is a floor: the node refuses to start a chain with fewer validators than it. It pads nothing.
What a validator signs
Every PrePrepare, Prepare and Commit carries an Ed25519 signature over a payload of its own kind: a domain tag (uninet-pbft-pre-prepare-v1, uninet-pbft-prepare-v1 or uninet-pbft-commit-v1), then the chain tag, the view, the sequence and the block hash. Binding the kind means a Prepare cannot be presented as a Commit, so a commit quorum proves that a quorum sent Commits. Binding the chain tag means a vote gathered on one chain does not verify on another, even though a node that co-hosts child chains signs for all of them with one key. The chain tag is derived from the chain id, the genesis block and the checkpoint settings, so a network reset that reuses a chain id and validator keys over a different genesis cannot replay the old network's votes, and a validator configured with different checkpoint settings casts no vote anyone counts.
ViewChange, NewView and Checkpoint sign their own domain-separated payloads bound to the same chain tag. A ViewChange commits to the digests of the certificates it carries, so a certificate cannot be bolted onto an already-signed message, and a NewView commits to each proof's identity and signature, so a leader cannot sign one proof set and ship another.
A Commit carries a second signature, block_signature, over the block's digest with its signature list emptied behind the tag uninet-block-commit-v1. Those are the signatures the node staples into the block, and the chain requires a quorum of distinct validators whose signature verifies before it applies a block. The engine verifies the block signature on receipt as well as the round signature and refuses the Commit if either fails, so one faulty validator cannot put an unverifiable signature into every node's certificate. Signing fails closed: if Ed25519 signing ever fails, the message carries an empty signature, the wrong length for Ed25519, and is rejected rather than circulating as though it were authenticated.
One engine, and it verifies
PbftEngine::with_keys is the only constructor, and every engine it builds verifies the signature on every PrePrepare, Prepare, Commit, Checkpoint, ViewChange and NewView against the sender's registered key. The node builds the primary chain's engine with it in both modes: over the validator set its config lists, or in single-node development mode over this node alone and its real key. Every co-hosted child chain's engine is built the same way. There is no non-verifying engine to fall back to.
What verification proves is bounded by where the keys come from: a validator's public key is whatever the genesis configuration lists for it, so "a vote from validator X" means a vote signed by the key the configuration named for X. Every validator must load the same configuration; the node's genesis handshake names a peer whose configuration differs.
Restarts: what a validator has signed
A validator writes down what it is about to sign before any vote leaves the node: the node's consensus journal writes a temporary file, syncs it and renames it over <data dir>/consensus/<chain id>.json. A restarted validator reloads it and cannot sign a second, different vote for a round it already voted in, the failure that would let one crash-and-restart look like equivocation. The journal keeps the rounds still inside the watermark window, so it stays small. One gap remains: on Windows the rename is not followed by a sync of its directory, so a power loss there can bring back the previous journal.
Checkpoints and watermarks
Validators run the Castro-Liskov checkpoint protocol. Each engine keeps a running digest of the committed log. Every checkpoint_interval blocks (K, 128 by default) a validator signs a Checkpoint of that digest; once a quorum has signed the same digest at the same sequence, it becomes the stable checkpoint and the new low watermark h. The message log is collected below it, and the engine accepts or proposes nothing beyond h + L, where L is the watermark window (256 by default).
A validator that finds a quorum vouching for a checkpoint it has not reached jumps to it: it abandons the round in flight, resumes ordering after the checkpoint, and refuses to lead until its chain has caught up by block sync. A validator whose own digest at that sequence differs refuses to adopt it, keeps its log as evidence and stops at its high watermark: a quorum vouching for a different history means this node, or the fault assumption, has failed. Every validator of a chain must use the same K and L; both are bound into the chain tag and compared at the genesis handshake.
The view-change path
Leader failure is detected by a timer. The timeout is armed when a round starts, at proposal and at acceptance of a PrePrepare, again when a prepare quorum lands so the commit phase gets a full interval of its own, and by note_pending_work when work is waiting. It is disarmed at finalization.
On expiry the node broadcasts a ViewChange targeting the next view, walking through a run of dead leaders rather than retrying one. Each attempt increments a failure counter, and the timeout grows as min(base × factor^failures, max): 10 seconds, doubling, capped at 300 by default. An operator can also ask for a view change, and that request is broadcast like one the timer raised. An incoming ViewChange must target a view strictly ahead of the current one and no more than max_view_lookahead (100 by default) ahead, because each distinct target view costs log space and that field is remote input. On four local nodes, losing the leader costs about 11 seconds, nearly all of it the first timeout.
Each ViewChange carries its sender's prepared certificates, the actual signatures over the block its sender prepared, and its latest stable checkpoint with the quorum that signed it. A receiver verifies every signature, rejects a repeated signer and requires the distinct-signer count to reach quorum; a node that never prepared a block cannot claim it did.
When a quorum of view changes names the same view, each node adopts that view itself; nothing sends a NewView. What a NewView would have carried is enforced on that path instead. A prepared lock binds the sequence to the block some validators prepared: the new leader re-proposes exactly that block or proposes nothing, and a node holding the lock refuses any other block at that sequence, so a new view cannot reassign a committed sequence. The highest proven checkpoint among the quorum's view changes is adopted, so the new view starts from it on every node. An arriving NewView is still fully validated: the view must be strictly ahead, the sender must lead it, its signature must cover exactly the proof set presented, and the proofs must come from a quorum of distinct validators.
What the design does not address
- The leader schedule is public. The leader of view
vis validatorv mod n, so anyone holding the validator list knows the next several leaders, and an adversary choosing whom to disrupt can plan ahead. The escalating timeout that prevents view-change thrashing also makes each successive failure cost longer. - The validator set changes by vote, one step at a time. A change voted on chain takes effect eight blocks later, and every node moves its engine to the new set right after the block that ends the old one, under a chain tag bound to the new set, so no vote cast under one set counts in another. A node that syncs from genesis trusts each set as the chain recorded it: keys removed long ago could still sign a fake history for such a node, which a trusted checkpoint in the network file is meant to close.
- A child chain's validators are its owner's choice. They are picked among the primary chain's validators. The stake behind each of them is locked on the primary chain, and nothing done on a child chain is slashed. A child that has lost too many validators to reach agreement cannot adopt the change that would replace them, since the set it has adopts each change.
- Only a double vote is slashed. A validator that signs two Commit votes for different blocks at one height in one view loses half the stake behind it and its seat, once any validator that received both reports them on chain. A leader that proposes two blocks in a view, a vote in another view, and downtime are not slashed, and a set of four keeps a validator proven faulty until a fifth joins.
- Safety assumes at most f faults. Past that bound no quorum argument holds and the protocol offers nothing.
- It has run on one small network. The hosted testnet is five validators, all run by one operator; it shows finality and failover across real links, not behaviour under sustained load, across continents or among operators who do not trust each other. Its numbers are on what runs today.
How it composes
A validator is a NodeId bound to an Ed25519 public key, so consensus membership is the same kind of identity the rest of the system runs on: a key, not an account in a table. The commit certificate stapled into a block header carries that binding forward, verifiable against the validator set long after the round has ended.
The blocks consensus orders are then applied by the chain, which runs their transactions under execution and checks the state root each header commits to. Ordering and execution stay separate concerns, and the proposal check is where they meet: a validator votes only for a block that extends its own chain.
Choosing validators and handing them one shared configuration is a deployment task, so it belongs with the rest of running a node. The assumption underneath it, that fewer than a third of your validators are faulty and that they fail independently, is one the threat model asks you to justify rather than inherit.