npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@johnhenry/raijin-consensus

v0.2.0

Published

PBFT consensus and leader rotation for the Raijin mesh rollup

Downloads

1,325

Readme

@johnhenry/raijin-consensus

npm version license

PBFT consensus and leader rotation for the Raijin mesh rollup — a browser-native rollup framework where the users ARE the validators.

Implements a simplified Practical Byzantine Fault Tolerance protocol: the leader broadcasts PRE-PREPARE with a proposed block, validators answer PREPARE, then COMMIT, and the block finalizes once a quorum of n - f commits is collected. View changes rotate the leader when it stops proposing. Every vote is signed and verified against a payload that names the chain, the validator set, the phase, the view and the sequence, so no signature is reusable anywhere else. Transport and timers are injected, so the engine runs over WebRTC, WebSockets, or an in-memory bus, and tests can drive time deterministically.

Contents

Install

npm install @johnhenry/raijin-consensus

Quorum math — read this before choosing a validator count

quorumSize() = n - f with f = floor((n - 1) / 3):

| validators (n) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | tolerated faults (f) | 0 | 0 | 0 | 1 | 1 | 1 | 2 | 2 | 2 | 3 | | quorum (q) | 1 | 2 | 3 | 3 | 4 | 5 | 5 | 6 | 7 | 7 |

Safety needs any two quorums to overlap in at least one honest node — 2q - n >= f + 1. q = n - f satisfies that at every n. At the canonical PBFT sizes (n = 3f + 1: 1, 4, 7, 10, …) it is exactly 2f + 1; everywhere else it is strictly larger, which is the point. q = 2f + 1 is not safe off those sizes, and at n = 2 or n = 3 it degenerates to q = 1 — one node reaching its own PREPARE and COMMIT quorum and finalizing blocks alone. That is what this package used to do, and it does not any more.

You still get no Byzantine tolerance below n = 4 — with f = 0 a single faulty node is one too many — but a faulty node can no longer finalize by itself: at n = 3 it needs all three.

Two more sharp edges:

  • chainId is required and has no default. It is signed into every vote. A default would be an id shared by every deployment that never chose one, and votes would then replay verbatim between them (testnet into mainnet, a fork into its parent). PBFTConsensus throws TypeError if it is missing.
  • The engine never builds blocks. The leader's block timer fires into a no-op; something external (BlockProducer in @johnhenry/raijin-validator) must construct a block and call propose().

What the engine authenticates, and what is still yours

Earlier versions of this package collected vote signatures without checking them and told you that authentication was your transport's job. It isn't any more. Be precise about the new line, because the half that is still yours has not moved.

The engine now guarantees, on every PRE-PREPARE, PREPARE, COMMIT and VIEW-CHANGE it acts on:

  • The signature is verified against the claimed sender, through the verify (SignatureVerifier) you inject. Since 0.1.0 that interface is verify(publicKey, signature, message) (it was (message, signature, publicKey)). A message that fails is dropped before it reaches any counter. verify is a required config field — there is no unverified mode to fall back into.
  • The signature is bound to its scope. A vote is signed over voteDigest({ phase, chainId, epoch, view, sequence, digest }), so one signature authorizes exactly one phase, of one round, at one sequence, on one chain, under one validator set. A PREPARE is not a COMMIT. A vote from view 4 is not a vote in view 5. A testnet vote is not a mainnet vote. A vote cast before a membership change is not a vote after it — epoch is a digest of the exact set and order, so a removed validator's still-valid signatures stop counting toward the quorum of the set that removed it.
  • chainId and epoch come from this node, never from the message. A peer cannot tell you which chain or which set its vote should count under; it can only produce a signature that matches your scope or does not.
  • A sender counts once. VIEW-CHANGE messages are deduplicated by sender, and PREPARE/COMMIT are collected into per-digest sets keyed by validator, so re-broadcasting cannot manufacture a quorum from one node.
  • Views only move forward. A recorded VIEW-CHANGE quorum stays valid forever, so replaying one would otherwise rewind the view and clear every in-flight round indefinitely. newView <= currentView is dropped, for both VIEW-CHANGE and NEW-VIEW.

Still yours:

  • Delivery. The engine never retries, never orders, never detects a dropped message. If your transport loses PREPAREs, rounds simply time out into view changes. Liveness is a transport property; only safety is defended here.
  • Sybil resistance and the membership list itself. from is checked for membership in the ValidatorSet you supply, and that is what the signature then proves. The engine has no opinion on who belongs in the set or how it changes — it will faithfully authenticate a vote from a validator you should never have admitted.
  • Confidentiality and DoS. Nothing here is encrypted, and signature verification happens per message, so an unauthenticated peer that can reach onMessage can still make you do work. Rate-limit at the transport.
  • The verifier's correctness. verify is injected. An implementation that returns true unconditionally reinstates every hole listed above; ed25519Verifier from @johnhenry/raijin-core is the real one.

Quick start

import { StateMachine, InMemoryStateStore, ed25519Verifier } from '@johnhenry/raijin-core'
import { PBFTConsensus, ValidatorSet } from '@johnhenry/raijin-consensus'

const consensus = new PBFTConsensus({
  identity: myPublicKey,             // 32 bytes
  chainId: 42n,                      // required, no default — see above
  validators: new ValidatorSet([myPublicKey, peerA, peerB, peerC]),
  transport,                         // NetworkTransport — your WebRTC/WebSocket bridge
  timer: { set: (ms, cb) => setTimeout(cb, ms), clear: clearTimeout },
  stateMachine: new StateMachine(new InMemoryStateStore(), verifier),
  sign: (msg) => wallet.sign(msg),
  verify: ed25519Verifier,           // required — every vote is checked with this
  blockTime: 2000,                   // default 2000
  viewTimeout: 10000,                // default 10000
})

consensus.onBlockFinalized((block, receipts) => console.log('final', block.header.number))
consensus.onViewChange((view) => console.log('new view', view))
consensus.start()

if (consensus.isLeader) await consensus.propose(block)

propose() throws unless this node is the current leader and phase is Idle — one consensus round at a time. On finalization the engine applies the block via stateMachine.applyBlock(), notifies handlers, and resets to Idle.

API

PBFTConsensus

new PBFTConsensus(config: PBFTConfig) — config fields: identity, chainId (required, bigint), validators, transport, timer, stateMachine, sign, verify (required, SignatureVerifier), optional blockTime (ms, default 2000), optional viewTimeout (ms, default 10000).

| Member | Purpose | | --- | --- | | start() / stop() | Arm/clear timers and begin/stop processing messages. Messages received while stopped are dropped. | | propose(block): Promise<void> | Leader-only. Broadcasts PRE-PREPARE (+ its own PREPARE) and increments the sequence. | | onBlockFinalized(handler) | (block, receipts) => void after quorum commit + state application. | | onViewChange(handler) | (newView: bigint) => void after a view change takes effect. | | currentView / currentSequence | bigint counters. | | phase | PBFTPhase: Idle → PrePrepared → Prepared → Committed → Idle. | | isLeader / currentLeader | Leader for the current view (round-robin over the validator set). | | running | Whether start() has been called. |

View changes: if the view timer expires without progress, the node broadcasts view-change for view + 1; once quorumSize() validly-signed view-change messages from distinct senders accumulate, everyone rotates, clears in-flight rounds, 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 cannot rewind the node.

Carry-over is partial: a new leader is prevented from proposing a conflicting block at a sequence that already reached a PREPARE quorum, but the original block is not automatically re-proposed. Full prepared-certificate carry-over is tracked as follow-up work.

ValidatorSet

new ValidatorSet(validators?: Uint8Array[]) — ordered set of 32-byte public keys.

  • add(pubkey) / remove(pubkey) / has(pubkey) — membership; add returns false on duplicates.
  • 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. It is a digest rather than a counter on purpose: two nodes that applied different changes would still agree on "epoch 2" and go on counting each other's votes, whereas disagreeing about who is in the set makes them disagree about the epoch. Order is included because leaderForView picks by index, so the same members in a different order are genuinely a different set. Memoized, and invalidated by add/remove. A ValidatorSet replacement or wrapper must provide it.
  • leaderForView(view) — deterministic round-robin: validators[view % n]. Throws on an empty set.
  • quorumSize() / maxFaults — the math above (n - f and floor((n - 1) / 3)).
  • size / all() / at(index) — inspection.

Every node must construct its ValidatorSet with the same keys in the same order, or leader election disagrees and nothing finalizes.

Types

PBFTConfig, PBFTPhase, NetworkTransport (broadcast/send/onMessage), ConsensusTimer + TimerHandle (set/clear — injectable for deterministic tests), and the message union ConsensusMessage = PrePrepareMessage | PrepareMessage | CommitMessage | ViewChangeMessage | NewViewMessage | TxGossipMessage | GenesisRequestMessage | GenesisResponseMessage.

Transports and the wire — BytesTransport, codecTransport, encodeConsensusMessage, decodeConsensusMessage

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, wsh stream) should implement the byte-level BytesTransport — broadcast(bytes), send(to, bytes), onMessage((from, bytes) => ...) — and be wrapped: new ValidatorNode({ transport: codecTransport(myBytesTransport), ... }). codecTransport encodes on send and decodes on receive with the canonical codec from @johnhenry/raijin-core (byte-level spec in its README). Payloads that fail to decode or are not consensus messages are dropped (report them via codecTransport(inner, { onError })); they never throw into your socket.
  • Either way from must be the authenticated peer key the transport established (e.g. from the 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 with a shape check (types, bigint views/sequences, Uint8Array digests/keys/signatures, well-formed new-view). They cover every member of ConsensusMessage, including tx-gossip and genesis-request/genesis-response. The test harness network is bytes-only and runs four validators through it.

voteDigest(vote: 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 — there is one definition and this is it.

voteDigest({ phase, chainId, epoch, view, sequence, digest }): Promise<Uint8Array>

H(tag ‖ 0x00 ‖ chainId ‖ epoch ‖ view ‖ sequence ‖ digest), integers LEB128, epoch and digest length-prefixed, so every field is self-delimiting and no two distinct votes share an input. phase is a VotePhase ('pre-prepare' | 'prepare' | 'commit' | 'view-change') selecting 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 deliberately: four of the six fields are bigints or byte strings of the same shape, and a transposed pair would silently sign a valid-looking signature over the wrong scope.

If your transport serializes to JSON, remember consensus messages carry bigints and Uint8Arrays — you need a replacer/reviver pair (see test/helpers.ts in the repo for a working one).

Provenance

Previously published unscoped as raijin-consensus: 0.0.1 (initial release, 2026-03-15), then 0.0.2 (2026-07-16 — published with a broken build, because the release workflow used plain npm publish, which does not rewrite workspace:* internal dependency specifiers into real resolved versions; that tarball was unpublished), then 0.0.3 (2026-07-16, same day — republished correctly via pnpm publish, the last unscoped version, live until this move).

Moved into the @johnhenry npm scope and restarted at 0.0.0 — no functional changes in the move. The workspace:* publish bug can no longer recur: the monorepo has since converted to npm workspaces with real semver ranges, and releases go out via npm publish --workspaces. See the root CHANGELOG for the full project history.

License

MIT