Consensus
@johnhenry/raijin-consensus (0.2.0) implements a simplified PBFT: leader broadcasts PRE-PREPARE with a block, validators answer PREPARE then COMMIT, and finalization happens at quorumSize() commits, after which the block is applied to the injected StateMachine. Leader rotation is round-robin per view; view changes evict a stalled leader. Transport and time are injected interfaces, which is what makes the engine testable and browser-portable. Every vote the engine acts on is signature-verified against a required, injected verify ((publicKey, signature, message) order) — see “What’s authenticated” below before assuming otherwise.
npm install @johnhenry/raijin-consensus<!-- In <head>, before any <script type="module"> or modulepreload. --><script type="importmap"> { "imports": { "@johnhenry/raijin-consensus": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js", "@johnhenry/raijin-core": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js" } }</script><script type="module"> import * as raijinConsensus from "@johnhenry/raijin-consensus";</script>The quorum math — read before choosing n
Section titled “The quorum math — read before choosing n”quorumSize() returns n − f where f = floor((n − 1) / 3) — not the 2f + 1 this page used to claim; that formula agreed with n − f by coincidence at some validator counts and silently diverged at others:
| validators (n) | tolerated faults (f) | quorum (n − f) |
|---|---|---|
| 1 | 0 | 1 |
| 2 | 0 | 2 |
| 3 | 0 | 3 |
| 4 | 1 | 3 |
| 5 | 1 | 4 |
| 6 | 1 | 5 |
| 7 | 2 | 5 |
Below n = 4 there is still no fault tolerance at all — one dishonest or offline validator among 1–3 stalls the chain — but with the real n − f formula, quorum at n = 2 or n = 3 requires every validator, not a single one: there’s no “a lone validator finalizes blocks alone” trap at those counts. Production reasoning should still start at n = 4, for tolerance rather than for this specific exploit.
What’s authenticated, and what’s still yours
Section titled “What’s authenticated, and what’s still yours”The engine used to collect vote signatures without checking them. It doesn’t any more: every PRE-PREPARE, PREPARE, COMMIT, and VIEW-CHANGE it acts on is checked against the required, injected verify (SignatureVerifier) — a message that fails is dropped before it reaches any counter, and there is no unverified mode to fall back into. Each signature is scoped to {phase, chainId, epoch, view, sequence, digest} via voteDigest(), so it authorizes exactly one phase, of one round, at one sequence, on one chain, under one exact validator set — a vote cast before a membership change stops counting toward the quorum of the set that removed the signer.
Still yours: delivery (the engine never retries or orders messages — a lost PREPARE just times out into a view change); the membership list itself (the engine faithfully authenticates a vote from any validator you put in the ValidatorSet, sybil resistance is your problem); confidentiality and DoS (nothing here is encrypted, and verification happens per message, so an unauthenticated peer that can reach onMessage can still make you do work); and no block building (the leader’s block timer is a no-op hook; something external must construct blocks and call propose() — in practice, BlockProducer from raijin-validator).
PBFTConsensus
Section titled “PBFTConsensus”import { ed25519Verifier } from '@johnhenry/raijin-core'import { PBFTConsensus, ValidatorSet, PBFTPhase } from '@johnhenry/raijin-consensus'
const consensus = new PBFTConsensus({ identity: myPublicKey, // Uint8Array(32) chainId: 42n, // required, no default -- scopes every signature validators: new ValidatorSet([a, b, c, d]), // same keys, same order, on every node transport, // NetworkTransport timer: { set: (ms, cb) => setTimeout(cb, ms), clear: clearTimeout }, stateMachine, // from @johnhenry/raijin-core sign: (msg) => wallet.sign(msg), verify: ed25519Verifier, // required -- every vote is checked with this; (publicKey, signature, message) blockTime: 2000, // ms, default 2000 viewTimeout: 10000, // ms, default 10000})
consensus.onBlockFinalized((block, receipts) => { /* applied to state already */ })consensus.onViewChange((newView) => { /* leader rotated */ })consensus.start()if (consensus.isLeader) await consensus.propose(block)| Member | Behavior |
|---|---|
start() / stop() |
Arm/clear timers; messages received while stopped are silently dropped. |
propose(block) |
Throws unless isLeader and phase === Idle — one round in flight at a time. Broadcasts PRE-PREPARE plus the leader’s own PREPARE, increments currentSequence. |
onBlockFinalized(h) |
Fires after quorum COMMIT and stateMachine.applyBlock() — receipts included. |
onViewChange(h) |
Fires when a view change takes effect (locally). |
phase |
PBFTPhase: Idle → PrePrepared → Prepared → Committed, then reset to Idle. |
currentView / currentSequence |
bigints; sequence follows the leader’s on accepted PRE-PREPARE. |
isLeader / currentLeader |
Round-robin over the set: leaderForView(view). |
Round mechanics worth knowing: a replica accepts PRE-PREPARE only from the exact leader of the message’s view, and only if the view matches its own and the block digest recomputes; PREPARE/COMMIT counting is per-digest with each node’s own vote included, so a single-validator round completes inside propose()’s promise chain with no transport traffic at all.
View changes: the view timer resets on any progress; on expiry a node broadcasts view-change for view + 1, and once quorumSize() validly-signed view-change messages from distinct senders accumulate, everyone rotates and the new leader takes over. A view change only ever moves forward — a message for a view at or below the current one is dropped, so a replayed quorum can’t rewind a node. Carry-over across the rotation is partial: a new leader is blocked from proposing a conflicting block at a sequence that already reached a PREPARE quorum, but the original block isn’t automatically re-proposed for you — full prepared-certificate carry-over (auto-re-proposing what was already prepared) is tracked as follow-up work in the repo, not shipped yet.
ValidatorSet
Section titled “ValidatorSet”| Member | Behavior |
|---|---|
new ValidatorSet(keys?) |
Ordered list of 32-byte public keys. Order is consensus-critical. |
epoch() |
Promise<Uint8Array> — a 32-byte digest of this exact set in this exact order. Every vote signature covers it, which is what stops votes crossing a membership change. A digest rather than a counter on purpose: two nodes that applied different changes would still agree on “epoch 2”. Memoized; invalidated by add/remove. |
add(k) / remove(k) / has(k) |
Membership; add rejects duplicates (false). |
leaderForView(view) |
keys[view % n]; throws on an empty set. |
quorumSize() / maxFaults |
The table above. |
size / all() / at(i) |
Inspection. |
Every node must build its set with the same keys in the same order — leader election is positional, so a different order means nodes disagree about who may propose and nothing finalizes. Membership changes at runtime are your problem to coordinate (the repo’s test harness exercises churn scenarios).
PBFTConfig, PBFTPhase, NetworkTransport (broadcast / send / onMessage), ConsensusTimer + TimerHandle (inject a mock for deterministic tests — the repo’s MockTimer advances time manually), and the message union ConsensusMessage = PrePrepareMessage | PrepareMessage | CommitMessage | ViewChangeMessage | NewViewMessage | TxGossipMessage | GenesisRequestMessage | GenesisResponseMessage. The last three are not consensus votes: PBFTConsensus ignores them, and ValidatorNode uses them for transaction gossip and genesis fetching.
Transports and the wire
Section titled “Transports and the wire”NetworkTransport is typed over message objects, which carry bigint and Uint8Array and so cannot be JSON.stringifyd. The contract:
- In-process transports (tests, same-thread buses) may pass the objects straight through.
- Anything that crosses a process boundary (WebSocket, WebRTC data channel, a byte stream) should implement the byte-level
BytesTransport—broadcast(bytes),send(to, bytes),onMessage((from, bytes) => ...)— and be wrapped:new ValidatorNode({ transport: codecTransport(myBytesTransport), ... }).codecTransportencodes on send and decodes on receive with the canonical wire codec fromraijin-core. Payloads that fail to decode, or are not consensus messages, are dropped (report them viacodecTransport(inner, { onError })); they never throw into your socket. - Either way
frommust be the authenticated peer key the transport established (e.g. from a handshake), never something read out of the payload. Consensus verifies every vote’s signature against it. Delivery should be reliable and ordered per peer.
encodeConsensusMessage(msg): Uint8Array / decodeConsensusMessage(bytes): ConsensusMessage are the same codec plus a shape check (types, bigint views/sequences, Uint8Array digests/keys/signatures, well-formed new-view), covering every member of ConsensusMessage.
voteDigest(vote) / NO_BLOCK_DIGEST
Section titled “voteDigest(vote) / NO_BLOCK_DIGEST”The bytes a validator signs for one vote, exported so a transport, relay or test can construct and check a vote without re-deriving the layout:
voteDigest({ phase, chainId, epoch, view, sequence, digest }): Promise<Uint8Array>H(tag ‖ 0x00 ‖ chainId ‖ epoch ‖ view ‖ sequence ‖ digest), with integers LEB128 and epoch/digest length-prefixed, so every field is self-delimiting and no two distinct votes share an input. phase is 'pre-prepare' | 'prepare' | 'commit' | 'view-change' and selects the domain tag; use NO_BLOCK_DIGEST (32 zero bytes) as the digest for VIEW-CHANGE, which commits to no block. It takes one object rather than positional arguments on purpose: four of the six fields are bigints or byte strings of similar shape, and a transposed pair would silently sign a valid-looking signature over the wrong scope.
Runnable: npm run example:05 prints the quorum table and drives a complete single-validator round from propose() through finalization.