Consensus

Four validators in lockstep

Entry #07 · 2026-02-12 · Devlog

Four validators in lockstep

Late, can't sleep, just got the test passing. Writing this with the test still on the screen.

Phase 3.5 is the moment everything I've built since 3.1 has to actually work. Real 4-validator BFT, real HTTP gossip, real Dilithium3 sigs flying between real AsentumNode instances on real OS-assigned ports. Not 4 engines in a for loop pretending to be a network — that was Phase 3.3 and it was fine for unit tests but it skipped the entire transport layer. This time the consensus protocol has to talk to itself across actual sockets.

The setup is dumb-simple. A bootDevnet() test helper:

for (let i = 0; i < 4; i++) {
  const node = new AsentumNode({
    dataDir: `${tempBase}/node-${i}`,
    chainId: 1337n,
    devnet: true,
    validatorIndex: i,
    manualBlockProduction: true,
  });
  await node.init();
  const rpc = new RpcServer({ node, port: 0 });
  const { port } = await rpc.start();
  stage1.push({ index: i, node, rpc, port });
}

// Wire peers AFTER everyone has a port
for (const me of stage1) {
  const peers = stage1
    .filter(n => n.index !== me.index)
    .map(n => `http://127.0.0.1:${n.port}`);
  const broadcaster = new VoteBroadcaster({ peers });
  me.node.onVoteProduced(v => broadcaster.broadcast(v));
  me.node.onProposalBroadcast(b => broadcaster.broadcastProposal(b));
}

Four nodes, four temp dirs, four RPC servers, four broadcasters pointed at the other three. Genesis is identical on every node because the four validator keys are derived from four hardcoded seeds (0x76 0x30+i 0xde ...). Everyone starts at the same state root.

Then I drive produceBlockNow on every node in parallel. Six ticks. 100ms between them.

[devnet] heights after 6 ticks: 6, 6, 6, 6

Six. Six. Six. Six.

Four separate node instances, each having committed the exact same chain through 6 rounds of real BFT. Every block proposed by a different validator (rotation by sorted address). Every block ratified by 3-of-4 votes flowing back over POST /consensus/vote. Every block's stateRoot matching across all 4 nodes' independent LevelDBs.

I wrote this test expecting to spend 2-3 hours debugging it. It passed on the first compile.

 ✓ test/consensus/devnet-4-validator.test.ts  (3 tests) 2672ms

Three seconds for the full devnet boot, six rounds of real BFT, and teardown. I just stared at 6, 6, 6, 6 for like a minute before screenshotting it for myself.

The thing I want to call out: the bootDevnet() helper is literally 100 lines of test code. The protocol I built for the 4-validator case is the same protocol that runs in single-validator federation mode. The same produceBlockInner. The same waitForQuorum. The same ingestConsensusVote. The only difference is who's at the other end of the socket.

The proposer-rotation skip is the one new thing in Phase 3.5. Previously the producer threw if it wasn't the scheduled proposer for the next round, which was fine in single-validator mode (you're always the proposer) but obviously broken with 4 nodes. Now it just returns null and waits for the actual proposer's /consensus/proposal to arrive. Every node runs the timer, only one builds per round.

Tomorrow I kill one of them.

— milkie

Don't miss the next entry.

Join the launch list and we'll send you a note whenever there's a new devlog entry, a research drop, or a real milestone.