UniNet

Chain

PBFT consensus

Three-phase PBFT where each vote is an Ed25519 signature over a domain-separated payload, a quorum is 2f+1 of the configured validator set, and whether signatures are enforced at all depends on which constructor the node used.

AvailablePBFT consensusIn developmentValidator production deployment

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, quorum.rs the arithmetic. The engine is a pure state machine — it consumes messages and returns actions, and the caller is responsible for putting the resulting bytes on the network.

The three phases

A round begins when the leader for the current view hashes its proposed block bytes and broadcasts a PrePrepare. A replica accepts it only while idle, only from the node that round-robin selection names as leader for that view, and only if BLAKE3(block_data) equals the block_hash the message carries. It then broadcasts a Prepare. When a validator has collected a quorum of Prepare messages naming the same (view, sequence, block_hash), it moves to Prepared and broadcasts a Commit. When it has collected a quorum of Commit messages, the block is final.

   Leader              Replica 1           Replica 2           Replica 3
     │                     │                   │                   │
     │── PrePrepare ──────▶│──────────────────▶│──────────────────▶│  leader-for-view checked
     │                     │                   │                   │  hash(block_data) checked
     │                     │                   │                   │
     │◀═══ Prepare ════════╪═══ all-to-all ════╪═══════════════════╡  tally 2f+1 → Prepared
     │                     │                   │                   │
     │◀═══ Commit ═════════╪═══ all-to-all ════╪═══════════════════╡  tally 2f+1 → 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 that was 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. The engine then increments its sequence, clears the block in flight, returns to Idle and disarms the view-change timer.

Two details are easy to assume wrongly. A node finalizes on a commit quorum whether it reached Prepared itself or is still in PrePrepared, so a validator that missed some Prepare traffic still commits. And the engine holds exactly one round in flight: handle_pre_prepare rejects any proposal arriving while the engine is not Idle, and Prepare and Commit messages must match the engine's current view and sequence exactly. There is no acceptance window and no buffering for a round the node has not reached — such messages are rejected, and redelivery is the network layer's problem.

| 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: sequence advances, state returns to Idle | | ViewChanging | this node initiated a view change — its timeout fired, or a caller asked | a view-change quorum, or a valid NewView |

Driving the engine

The engine never touches the network. handle_message returns at most one action — broadcast a Prepare, broadcast a Commit, finalize a block, or adopt a new view — and the host acts on it. Starting a view change is not one of them: that path returns a ConsensusMessage directly from initiate_view_change or from the timer tick, because it originates locally rather than in response to a peer. Two of the generic ConsensusEngine trait methods cannot express what the protocol needs: view_change() returns () and so discards the ViewChange it just produced, and handle_message() returns only the messages to broadcast, dropping a finalized block. PbftConsensusEngine therefore exposes propose_ex, handle_message_ex and view_change_ex for callers that can reach the network. The node's network manager uses propose_ex, handle_message_ex, tick_view_change_ex and note_pending_work; view_change_ex is reached only through the trait method and the crate's own tests. A ViewChange that is produced but never broadcast can never reach quorum, so a node that only initiates one locally has quietly stopped participating.

handle_message_ex also drives the view-change clock on every inbound message and appends any ViewChange that fires to the same broadcast list, so a leader that proposed and then went silent is caught by the next message any peer sends. The harder case — a leader that proposes nothing at all, and therefore generates no traffic to drive anything — needs the node's own tick loop, which is why tick_view_change_ex and note_pending_work exist as separate entry points. The clock reads wall-clock milliseconds since the epoch and yields 0 if that fails; elapsed time saturates at zero, so a broken clock fails toward not firing a view change rather than firing one continuously.

Per-round state lives in a message log keyed by (view, sequence, node_id, msg_type), which is what enforces one vote per validator per round: a repeat is dropped before it is counted. View-change entries are stored under sequence 0 and are deliberately exempt from the sequence-keyed sweep — a sweep that removed their dedup keys while leaving the messages in place would let one validator's ViewChange be replayed into a second vote. They are pruned by view instead, message and key together.

Quorum arithmetic

quorum.rs is the whole of it. max_faulty(n) = (n - 1) / 3 in integer division, and quorum_threshold(n) = 2 * max_faulty(n) + 1. The prepare tally, the commit tally, the view-change tally and the distinct-signer count inside a prepared certificate all compare against that one threshold.

| Validators (n) | Faults tolerated (f) | Quorum (2f+1) | Note | | --- | --- | --- | --- | | 4 | 1 | 3 | smallest set with f ≥ 1 | | 5 | 1 | 3 | MIN_CHAIN_NODES, the default minimum | | 7 | 2 | 5 | — | | 12 | 3 | 7 | MIN_FOUNDATIONAL_NODES | | 21 | 6 | 13 | — |

A test walks every n from 4 to 100 and asserts three invariants at each: 2q ≥ n, so two disjoint quorums cannot exist; 3f < n, so the fault budget is satisfiable; and q ≤ n - f, so a quorum is reachable from honest nodes alone.

PbftConfig::min_validators defaults to 5, and both engine constructors panic below it. That value bounds how small a set the chain will run, not the arithmetic — the formulas are correct for any n ≥ 1. A configured minimum below 4 yields f = 0, which is a validator set with no fault tolerance at all.

What a validator signs

Every PrePrepare, Prepare and Commit carries an Ed25519 signature over signable_payload(view, sequence, block_hash): 48 bytes, being the view and sequence as little-endian u64 followed by the 32-byte hash. The signature covers those fields directly rather than the JSON envelope the message travels in, so re-encoding cannot invalidate a signature and cannot change what was signed.

ViewChange and NewView sign different payloads, each prefixed with its own domain separator — uninet-pbft-view-change-v1 and uninet-pbft-new-view-v1. The prefixes keep the payload spaces disjoint, so a signature harvested from a Prepare cannot be replayed as a view-change vote. A prepared certificate has a third separator, uninet-pbft-prepared-cert-v1, and its digest length-prefixes each signature so two different certificates cannot encode to identical bytes.

A Commit carries a second, distinct signature. Commit.signature binds the vote to a round and is meaningful only to a node participating in it. Commit.block_signature signs block_commit_payload(digest) — the tag uninet-block-commit-v1\0 followed by the block's digest with its signature list emptied. That definition lives in uninet-types because both sides need it and the dependency runs one way: uninet-chain depends on uninet-consensus, so consensus cannot reach back for it. Consensus produces the signature during the round, and Block::commit_digest in uninet-chain reconstructs the same preimage afterwards. BlockHeader::verify_signatures then counts distinct validators whose signature verifies and requires 2f+1 of them, which is how a block sealed by a round you never observed remains checkable.

block_signature is defaulted for wire compatibility with peers that predate it, and the default is an empty signature — the wrong length for Ed25519 — so it fails verification instead of counting. The block-commit and view-change signing helpers apply the same convention if signing itself fails: an empty signature rather than a plausible-looking one. The round-vote helper sign_payload does not follow it — its fallback is 64 zero bytes, the right length and still not a signature any key verifies.

Which engine you are running

This is the part an operator has to get right, because the two constructors behave identically until someone lies.

| | PbftEngine::new / PbftConsensusEngine::new | PbftEngine::with_keys / PbftConsensusEngine::with_keys | | --- | --- | --- | | Takes | (node_id, validators: Vec<NodeId>, config) | (node_id, secret_seed: [u8; 32], validators: Vec<(NodeId, PublicKey)>, config) | | verify_signatures | false | true | | Where keys come from | derived from each public NodeId: BLAKE3(node_id ‖ "-dev-consensus-seed") | provisioned per validator | | Check on a vote signature | verify_msg returns true without looking | verified against the sender's key; failure rejects the message | | Check on ViewChange / NewView | SignerSet::verify returns true without looking | verified; unverifiable proofs do not count toward quorum | | Enforced either way | sender is a member of the validator set; PrePrepare comes from the leader for the view; hash(block_data) == block_hash; one vote per validator per round | the same |

Under the non-verifying engine, membership and structure are still checked, so an outsider cannot vote. What is not checked is whether the node claiming to be validator k holds validator k's key — so one peer can assemble an entire quorum by emitting votes under other members' identities. The derived dev keys make this worse rather than better: the seed is a hash of a public NodeId, so anyone can compute any validator's keypair and produce signatures that would verify. Checking them would prove nothing, which is why the code does not.

The node binary chooses between the two in bin/uninet-node/src/state.rs. use_real_keys is set from whether the config file supplies a validator list. With one, the node parses each validator's Ed25519 public key and calls with_keys. With none, the chain's validator set is this node alone, and the consensus set is that one entry padded up to min_validators with filler entries whose public keys are the byte patterns [i; 32], passed to new. Those fillers correspond to no private key that exists, which is why the chain's own set is left unpadded: block quorum is 2f+1 of the chain's set, and padding it would demand signatures nobody could produce. The same flag surfaces at /api/chain/status as multi_node, which reads true only when real validator keys and a p2p transport are both present.

Whether a PBFT round runs at all is a third deployment question. The loop that proposes blocks, feeds inbound messages to the engine and drives the view-change timer lives in the node's network manager, which starts only when the node is configured with a p2p transport. Without one, a submitted transaction takes build_and_apply_single: one transaction, one block, signed by this node and applied to the chain directly. No PrePrepare is emitted and no vote is counted. The multi-process path is exercised by bin/uninet-node/tests/multinode_bft.rs, which launches five real node processes over libp2p — an #[ignore]d test, run explicitly rather than in the default suite.

The view-change path

Leader failure is detected by a timer, not by a peer's accusation. The timeout is armed when a round starts — at proposal, at acceptance of a PrePrepare, and again when a prepare quorum lands so the commit phase gets a full interval of its own — and disarmed at finalization. A leader that proposes nothing emits no traffic at all, so the engine exposes note_pending_work, which arms the timer when the engine is idle and no timeout is already running. Whether there is work waiting is the caller's judgement, not the engine's: the node calls it on a tick only when its mempool is non-empty, so a chain with nothing to order does not rotate leaders for lack of anything to do.

On expiry the node broadcasts a ViewChange targeting max(view, previous_target) + 1, so a run of dead leaders is walked through rather than retried at the same view. 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 incoming ViewChange must target a view strictly greater than the current one and no more than max_view_lookahead (default 100) ahead, because the message log allocates a bucket per distinct target view and that field is remote input.

Each ViewChange carries a prepared certificate when its sender has one. A node that reached a prepare quorum before abandoning a round puts the actual Prepare signatures into its ViewChange — validator IDs paired with signatures over exactly the bytes those validators signed live. A receiver verifies every one, rejects a repeated signer, and requires the distinct-signer count to reach quorum; a node that never prepared the block emits an empty vector and cannot claim otherwise. The enclosing ViewChange signs the certificate digests, so a certificate cannot be swapped into an already-signed message. At most eight certificates are accepted per message, because each costs a quorum's worth of Ed25519 verifications on every validator that receives it.

Two paths can advance the view, and only one of them runs. What a node actually does is count ViewChange messages: when a quorum of them names the same target view, handle_view_change adopts that view, clears the block in flight and returns to Idle. Nothing in the workspace sends a NewViewcreate_new_view has no caller outside the crate's own tests.

The NewView receive path is implemented and reachable through handle_message regardless. create_new_view computes the resume sequence as the highest committed last_sequence across the proofs, plus one; if a certificate exists at that sequence, the leader must re-propose that exact block, and supplying a different one — or none — returns an error rather than a message it has no right to send. validate_new_view re-derives the same obligation at the receiver, and additionally requires that the new view is strictly ahead of the current one, that the sender is the correct leader for it, that the leader's signature covers the exact proof set presented, and that the proofs come from a quorum of distinct authenticated validators. Counting the length of the proof vector instead would make [v1, v1, v1] a quorum; the code counts a set.

Because no node constructs a NewView, that re-proposal obligation never binds a running network. On the path that does run, a node adopting a view from a ViewChange quorum discards the block in flight without re-deriving anything from the certificates it just verified, and the leader of the new view proposes whatever its own mempool gives it at the sequence the engine is already on.

What the design does not address

  • There is no checkpoint or watermark protocol. No checkpoint message, stable-checkpoint state or watermark bound exists anywhere in the workspace. Pruning is a sliding window instead: on finalization the engine drops prepares and commits below sequence - 10, and drops view-change state below the current view. For a long-running validator that means the window only advances when blocks commit — a chain that stops finalizing stops pruning — and the engine's finalized vector of (sequence, block_hash) is appended to on every block and never truncated, so it grows for the lifetime of the process.
  • A lagging replica is not caught up by the protocol. Without checkpoints there is no state transfer inside consensus, and a node whose sequence has fallen behind rejects live round traffic outright rather than buffering it. Recovery runs over the separate block-sync request path, which is not a phase of PBFT and carries none of its guarantees.
  • The leader schedule is public. leader_index(view, n) = view % n is deterministic and derivable by anyone holding the validator list, so an adversary choosing whom to disrupt knows the next several leaders in advance. The escalating timeout that prevents view-change thrashing also means each successive failure costs longer.
  • The validator set is fixed at construction. The engine exposes no membership mutation. Adding or removing a validator means building a new engine, and until then every node computes quorum against the set it was constructed with.
  • Consensus agrees on a proposal, not on its contents. A replica checks that the block hash matches the block bytes; it does not examine which transactions the leader included or in what order. A leader can omit or reorder transactions within its own proposal without violating anything at this layer. Transaction validity and the state-root commitment are checked by the chain when the block is applied — a different boundary, and one worth reading separately.
  • Safety assumes at most f faults. Past that bound, 2f+1 is no longer more than the honest nodes can supply and the protocol offers nothing. At a configured minimum below 4, f is zero and one node is the entire quorum.

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 is what 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 the header commits to. Ordering and execution are deliberately separate concerns: agreement on bytes is a cheaper property than agreement on results, and only the first is what this layer provides.

Choosing a constructor is a deployment decision rather than a code change, 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.

What is not finished

Vote signature enforcement is opt-in at construction and the default single-node path does not verify, and there is no checkpoint or watermark collection, so a long-running validator accumulates message log without bound. Confirm which engine your deployment constructs before relying on it.

See the full build status