Files
consensus/engine/chain/multinode_reconverge_test.go
zeekayandHanzo Dev af808b25ad validator: node-bound proof of possession, one key per node
The forged certificate that reached crates.io was killed at verification, but a
red review showed the hole had only moved to registration: a key was admitted on
key_validate alone, which proves a point is on the curve and nothing about who
holds its secret, and duplicate keys were merged into one validator carrying many
node ids — so a floor written to require many distinct signers was cleared by one
holder registering one key under several ids.

This is the Go oracle for the standard ruled in luxfi/conformance: it defines the
bytes Rust and C++ reproduce.

validator/pop is the primitive. A registrant signs its own identity followed by
its own key — node(20, ids.NodeID) ‖ pubkey(48, compressed G1), 68 bytes, no
separator — under the proof-of-possession domain BLS_POP_..._POP_, distinct from
the vote's ..._NUL_ so a vote is never a proof and a proof never a vote. The node
is bound because the pubkey-only IETF proof travels with the key: bound to
nothing, it re-registers under any identity. 20 bytes because a Lux validator's
identity IS its NodeID; the 32-byte consensus Id names blocks, not nodes, and has
no canonical inverse, so widening would invent a second name for one node.

validator/registration is the admission rule, written once: encoding, then
possession, then uniqueness, then weight — a pairing on non-points is undefined,
and weight counted before uniqueness is weight counted twice. A set is admitted
whole or refused whole, because a set that silently drops a signer no longer
describes its own weight. FlattenValidatorSet's merge of duplicate keys is retired
here: it was the only thing that made many-nodes-one-key representable, and after
the uniqueness rule that state does not exist to normalize. Order and
PublicKeyBytes still match the upstream flatten exactly, so a proven set and a
flattened-distinct set are the same set.

engine/chain keys the cert store on the DECISION — the signed canonical identity
— not the transport envelope id, which no signature covers: two envelopes of one
decision were two store entries for one finality fact, and a verified cert could
be filed under a block it never attested. Keyed by decision, a cert is found by
anything that finalized that decision, and Put is idempotent per decision.

The pubkey-only P-chain proof (luxfi/node AddValidatorTx) is NOT touched here:
moving it node-bound is a network upgrade, every validator re-proves, and it is
activation-gated as the deliberate last step. Anywhere a validator set that
shares a key across node ids is admitted today, this now refuses it — that is the
intended behaviour, and the coordination point with the P-chain step.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:10:24 -07:00

165 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// multinode_reconverge_test.go — the DEFINITIVE partition/heal re-convergence proof.
//
// The owner's reliability gate: a validator that falls behind must REJOIN and catch the
// tip in seconds, with NO operator snapshot-restore. This test partitions one node off a
// K=5 majority, advances the majority ~35 finalized blocks (each with a real α-of-K cert),
// heals the node, and asserts it re-converges to the majority tip END-TO-END — driven by
// the node's OWN engine (requestCatchup → RequestAncestors), not a hand-fed quorum.
//
// It exercises the whole rejoin fix set together: #1 (proposervm build-pref never poisons —
// modeled by the engine building on lastAccepted through the gap), #2 (steer only to held
// tips), #3 (peer-select reaches a serving peer — busCatchup always finds one), #5 (a
// behind node discovers it must fetch the CERTS, not just blocks), #4 (contiguous
// oldest-first cert-accept makes monotonic progress). The cert-carry accept path
// (AcceptCatchupBlock) is the SAME audited predicate live finality uses — no weaker
// authority is introduced by catch-up.
package chain
import (
"context"
"testing"
"time"
"github.com/luxfi/ids"
)
// busCatchup is the harness's node-layer catch-up transport (NetworkConfig.Catchup). It
// models the FIXED node layer: when the engine calls requestCatchup(missingID) it serves
// the requester its missing FINALIZED gap — each block paired with the real cert the
// serving peer stored on finalize (CertForBlock) — oldest-first, via AcceptCatchupBlock.
// Delivery is asynchronous (a fresh goroutine) so it never re-enters the caller's engine
// lock, exactly like the real network round-trip. Selection (#3) always reaches a serving
// peer; the served pairs let the behind node finalize the gap on VERIFIED certs (#5) with
// no re-vote.
type busCatchup struct {
net *simNet
selfIdx int
}
func (c *busCatchup) RequestAncestors(_ ids.ID, _ ids.ID, _ ids.ID, _ ids.NodeID) error {
self := c.net.nodes[c.selfIdx]
// #3: pick the most-finalized reachable peer as the server (never EmptyNodeID / self).
var server *simNode
var serverH uint64
for i, n := range c.net.nodes {
if i == c.selfIdx || !n.reachable() {
continue
}
if _, h, ok := n.rt.FinalizedLedger(); ok && (server == nil || h > serverH) {
server, serverH = n, h
}
}
if server == nil {
return nil
}
// This node's finalized floor — serve strictly above it, oldest-first (#4 contiguity).
var floor uint64
if _, h, ok := self.rt.FinalizedLedger(); ok {
floor = h
}
if serverH <= floor {
return nil
}
go func() {
ctx := context.Background()
for h := floor + 1; h <= serverH; h++ {
id, ok := server.rt.FinalizedBlockAtHeight(h)
if !ok {
continue
}
server.vm.mu.Lock()
blk := server.vm.blockByID[id]
server.vm.mu.Unlock()
if blk == nil {
continue
}
// The block first: a cert is filed under the decision it proves, and the
// block is what names it.
certBytes, ok := server.rt.Transitive.CertForBlock(blk)
if !ok {
continue // cert aged out of the served window — a real node bootstraps instead
}
if !self.reachable() {
return
}
// SAME audited cert predicate as live finality; idempotent on an already-decided
// height. A reject (bad/forged cert) does NOT halt the batch — try the next.
_ = self.rt.AcceptCatchupBlock(ctx, blk.Bytes(), certBytes)
}
}()
return nil
}
// reconvergeTO bounds the "catch up in SECONDS" claim: after heal + a single trigger block,
// the rejoiner must reach the majority's pre-heal finalized tip well inside this window.
const reconvergeTO = 15 * time.Second
// TestMultiNode_PartitionHeal_ReconvergesInSeconds is the end-to-end rejoin proof.
func TestMultiNode_PartitionHeal_ReconvergesInSeconds(t *testing.T) {
const gap = 35 // majority advances ~35 finalized blocks while node 4 is partitioned
net := newSimNet(t, 5, prodParams5())
rejoiner := 4
// PARTITION node 4 off the majority BEFORE any block: it will miss the entire gap.
net.down(rejoiner)
// Advance the majority (nodes 0..3 — the exact α=4 quorum) by `gap` finalized blocks.
parentID := ids.Empty
parentRoot := simGenesisRoot()
var tipID ids.ID
var tipRoot ids.ID
for h := uint64(1); h <= gap; h++ {
blk := newHonestBlock(parentID, parentRoot, h, "reconverge-gap")
net.build(int(h%4), blk) // rotate the proposer across the up majority
if !waitFor(emergeTO, func() bool { all, fork := net.finalizedEverywhere(blk); return all && !fork }) {
t.Fatalf("majority must finalize gap height %d (heads=%v)", h, net.headsAtHeight(h))
}
parentID, parentRoot = blk.ID(), blk.stateRoot
tipID, tipRoot = blk.ID(), blk.stateRoot
}
// The rejoiner has finalized NOTHING (it was partitioned the whole time).
if _, h, ok := net.nodes[rejoiner].rt.FinalizedLedger(); ok && h != 0 {
t.Fatalf("precondition: rejoiner should be at finalized height 0 before heal, got %d", h)
}
majTip, majH := tipID, uint64(gap)
t.Logf("BEFORE heal: majority finalized tip = height %d (%s); rejoiner = height 0 (partitioned, missed %d blocks)", majH, majTip, gap)
// HEAL node 4 and TRIGGER re-convergence with ONE new block: the rejoiner receives its
// gossip, sees the parent chain missing, and its OWN engine drives requestCatchup →
// busCatchup serves the finalized gap with certs → it finalizes 1..gap, then this block.
net.nodes[rejoiner].setUp(true)
trigger := newHonestBlock(tipID, tipRoot, majH+1, "reconverge-trigger")
net.build(0, trigger)
start := time.Now()
ok := waitFor(reconvergeTO, func() bool {
_, h, set := net.nodes[rejoiner].rt.FinalizedLedger()
return set && h >= majH // reached (at least) the majority's pre-heal tip
})
elapsed := time.Since(start)
if !ok {
_, h, _ := net.nodes[rejoiner].rt.FinalizedLedger()
t.Fatalf("rejoiner did NOT re-converge within %s: still at finalized height %d, majority was %d", reconvergeTO, h, majH)
}
// NO FORK: every height the rejoiner finalized must match the majority's canonical head.
for h := uint64(1); h <= majH; h++ {
mine, okMine := net.nodes[rejoiner].rt.FinalizedBlockAtHeight(h)
theirs, okTheirs := net.nodes[0].rt.FinalizedBlockAtHeight(h)
if okMine && okTheirs && mine != theirs {
t.Fatalf("FORK: rejoiner finalized %s at height %d, majority finalized %s", mine, h, theirs)
}
}
_, rejH, _ := net.nodes[rejoiner].rt.FinalizedLedger()
t.Logf("AFTER heal: rejoiner re-converged to finalized height %d in %s (bound %s) — no fork, no restore", rejH, elapsed.Round(time.Millisecond), reconvergeTO)
}