mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
The module already called itself github.com/luxfi/node while containing no node in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and the Go column of the matrix measured a VM plugin. A repo that fetches the thing it claims to be has not replaced it, so the Go sources move in — 1515 files, the daemon and everything under it — and the build reaches outside this module for nothing. Rust and C++ sit beside it unchanged and are still measured against it by the differential. That is the point of one repo: the reference implementation and the two ports are the same checkout, so parity is a thing you can run rather than a thing you assert. Verified: builds, and the closure carries no lux-private — checked at build time rather than believed. The daemon starts twelve chains on a local network (P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
201 lines
9.3 KiB
Go
201 lines
9.3 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// windower_determinism_test.go — the SAFETY/LIVENESS boundary of the proposer
|
|
// schedule, the half that makes the down/wedged/forked-proposer fix BFT-safe.
|
|
//
|
|
// The consensus engine's liveness fix (re-solicit a substitute's block until it
|
|
// finalizes) is only safe because WHICH node is the designated proposer for a slot
|
|
// is DETERMINISTIC across every honest node and rotates per slot:
|
|
//
|
|
// - DETERMINISM (safety): every honest node computes the IDENTICAL expected
|
|
// proposer for a given (chainID, height, pChainHeight, slot). So an honest node
|
|
// accepts a SIGNED block for slot S iff it was signed by ExpectedProposer(S) —
|
|
// a node proposing OUT OF TURN (before its slot) is rejected by EVERY honest
|
|
// node (Verify's errUnexpectedProposer). An attacker cannot make nodes disagree
|
|
// on the eligible proposer and thereby flood competing accepted blocks / fork.
|
|
//
|
|
// - ROTATION (liveness): consecutive slots designate (in general) DIFFERENT
|
|
// proposers, so a down/wedged/forked designated proposer for slot S is routed
|
|
// around: at slot S+1 (5s later) a different validator is designated and builds
|
|
// a signed block the rest accept. This is the upstream proposer-window mechanism,
|
|
// byte-for-byte (windower.go is identical to ava's), and the reason a faulty
|
|
// leader cannot halt the chain.
|
|
//
|
|
// These properties are asserted across many heights, slots, and seeds — not one
|
|
// hand-picked case — so the BFT boundary holds over the input space.
|
|
package proposer
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// expectedProposerInTurn models the verify-side decision (verifyPostDurangoBlockDelay
|
|
// / shouldBuildSignedBlockPostDurango): a SIGNED block for `slot` is "in turn" iff
|
|
// its signer equals the slot's deterministic designated proposer. Out-of-turn ⇒
|
|
// the proposervm Verify returns errUnexpectedProposer on every honest node.
|
|
func expectedProposerInTurn(t testing.TB, w Windower, height, pChainHeight, slot uint64, signer ids.NodeID) bool {
|
|
t.Helper()
|
|
exp, err := w.ExpectedProposer(context.Background(), height, pChainHeight, slot)
|
|
require.NoError(t, err)
|
|
return exp == signer
|
|
}
|
|
|
|
// TestExpectedProposer_DeterministicAcrossInstances proves the SAFETY half: two
|
|
// INDEPENDENT windower instances (modelling two distinct honest nodes, each with
|
|
// its own validator-state object over the SAME set) compute byte-identical expected
|
|
// proposers for every (height, slot). If they could disagree, two honest nodes would
|
|
// accept different signed blocks for one slot → competing accepted blocks → fork.
|
|
func TestExpectedProposer_DeterministicAcrossInstances(t *testing.T) {
|
|
require := require.New(t)
|
|
const numValidators = 11
|
|
|
|
validatorIDs, vdrStateA := makeValidators(t, numValidators)
|
|
vdrStateB := makeValidatorState(t, validatorIDs) // independent state, same set
|
|
|
|
// Two nodes that agree on chainID + netID must agree on the schedule.
|
|
nodeA := New(vdrStateA, netID, fixedChainID)
|
|
nodeB := New(vdrStateB, netID, fixedChainID)
|
|
|
|
for height := uint64(0); height < 50; height++ {
|
|
for slot := uint64(0); slot < 3*MaxLookAheadSlots; slot += 37 { // sample the slot space cheaply
|
|
pA, err := nodeA.ExpectedProposer(context.Background(), height, 0, slot)
|
|
require.NoError(err)
|
|
pB, err := nodeB.ExpectedProposer(context.Background(), height, 0, slot)
|
|
require.NoError(err)
|
|
require.Equal(pA, pB,
|
|
"two honest nodes disagreed on the designated proposer for height=%d slot=%d (%s != %s) — "+
|
|
"non-deterministic eligibility breaks the BFT boundary (competing accepted blocks / fork)",
|
|
height, slot, pA, pB)
|
|
|
|
// And the designated proposer is always a real member of the set.
|
|
require.Contains(validatorIDs, pA,
|
|
"expected proposer %s for height=%d slot=%d is not in the validator set", pA, height, slot)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExpectedProposer_DeterministicAcrossSeeds proves determinism is a property of
|
|
// the (chainID, height, slot) inputs, not of any per-instance random state: the same
|
|
// inputs always yield the same proposer, and DIFFERENT chainIDs generally yield a
|
|
// DIFFERENT schedule (so the seed actually mixes chainID — no cross-chain schedule
|
|
// collision an attacker could exploit). Run over many seeds.
|
|
func TestExpectedProposer_DeterministicAcrossSeeds(t *testing.T) {
|
|
require := require.New(t)
|
|
validatorIDs, vdrState := makeValidators(t, 21)
|
|
|
|
differs := 0
|
|
const seeds = 64
|
|
for s := 0; s < seeds; s++ {
|
|
cid := ids.ID{byte(s), byte(s >> 8), 0x5a}
|
|
w1 := New(vdrState, netID, cid)
|
|
w2 := New(makeValidatorState(t, validatorIDs), netID, cid)
|
|
// Repeat-call determinism + cross-instance determinism for this seed.
|
|
for slot := uint64(0); slot < 16; slot++ {
|
|
p1a, err := w1.ExpectedProposer(context.Background(), 7, 0, slot)
|
|
require.NoError(err)
|
|
p1b, err := w1.ExpectedProposer(context.Background(), 7, 0, slot)
|
|
require.NoError(err)
|
|
p2, err := w2.ExpectedProposer(context.Background(), 7, 0, slot)
|
|
require.NoError(err)
|
|
require.Equal(p1a, p1b, "repeated call non-deterministic at seed=%d slot=%d", s, slot)
|
|
require.Equal(p1a, p2, "cross-instance non-deterministic at seed=%d slot=%d", s, slot)
|
|
require.Contains(validatorIDs, p1a)
|
|
}
|
|
// Compare schedules of consecutive chainIDs at one slot to confirm chainID mixes.
|
|
if s > 0 {
|
|
prev := New(vdrState, netID, ids.ID{byte(s - 1), byte((s - 1) >> 8), 0x5a})
|
|
a, _ := prev.ExpectedProposer(context.Background(), 7, 0, 0)
|
|
b, _ := w1.ExpectedProposer(context.Background(), 7, 0, 0)
|
|
if a != b {
|
|
differs++
|
|
}
|
|
}
|
|
}
|
|
// The schedule must depend on chainID for the overwhelming majority of seed pairs
|
|
// (a constant schedule would mean chainID is ignored — a real schedule-collision bug).
|
|
require.Greater(differs, seeds/2,
|
|
"chainID barely affects the schedule (%d/%d seed pairs differ) — seed derivation may ignore chainID",
|
|
differs, seeds)
|
|
}
|
|
|
|
// TestExpectedProposer_OutOfTurnSignerIsRejected proves the SAFETY boundary the
|
|
// fallback must NOT breach: for every slot, EXACTLY ONE validator is "in turn" (the
|
|
// designated proposer) and EVERY OTHER validator is "out of turn" — so an honest
|
|
// node accepts a signed block for that slot ONLY from the designated proposer and
|
|
// REJECTS an out-of-turn (early / wrong) proposer's signed block. This is the
|
|
// windower half of verifyPostDurangoBlockDelay's errUnexpectedProposer.
|
|
func TestExpectedProposer_OutOfTurnSignerIsRejected(t *testing.T) {
|
|
require := require.New(t)
|
|
validatorIDs, vdrState := makeValidators(t, 11)
|
|
w := New(vdrState, netID, fixedChainID)
|
|
|
|
for slot := uint64(0); slot < 64; slot++ {
|
|
inTurnCount := 0
|
|
var designated ids.NodeID
|
|
for _, id := range validatorIDs {
|
|
if expectedProposerInTurn(t, w, 9, 0, slot, id) {
|
|
inTurnCount++
|
|
designated = id
|
|
}
|
|
}
|
|
require.Equal(1, inTurnCount,
|
|
"slot %d must have EXACTLY ONE in-turn proposer (got %d) — otherwise two signed blocks are both "+
|
|
"'in turn' and an out-of-turn proposer is accepted (the early-acceptance fork hole)", slot, inTurnCount)
|
|
|
|
// Every non-designated validator is out of turn for this slot → its signed
|
|
// block is rejected by Verify on every honest node.
|
|
for _, id := range validatorIDs {
|
|
if id == designated {
|
|
continue
|
|
}
|
|
require.False(expectedProposerInTurn(t, w, 9, 0, slot, id),
|
|
"validator %s is NOT the designated proposer for slot %d yet was treated as in-turn — "+
|
|
"an out-of-turn proposal must be rejected", id, slot)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExpectedProposer_SlotRotation_RoutesAroundDownProposer proves the LIVENESS
|
|
// half: across a window of consecutive slots the designated proposer rotates over
|
|
// MANY distinct validators, so a single down/wedged/forked designated proposer for
|
|
// one slot is routed around — a later slot designates a healthy validator who builds
|
|
// a signed block the honest majority accepts and finalizes. (This is why a faulty
|
|
// leader cannot halt the chain.)
|
|
func TestExpectedProposer_SlotRotation_RoutesAroundDownProposer(t *testing.T) {
|
|
require := require.New(t)
|
|
const numValidators = 11
|
|
_, vdrState := makeValidators(t, numValidators)
|
|
w := New(vdrState, netID, fixedChainID)
|
|
|
|
// Pick slot 0's designated proposer as the "down/wedged/forked" leader.
|
|
down, err := w.ExpectedProposer(context.Background(), 13, 0, 0)
|
|
require.NoError(err)
|
|
|
|
// Within a small number of slots after it, a DIFFERENT (healthy) validator must be
|
|
// designated — i.e. the down leader is routed around quickly. Assert a healthy
|
|
// substitute appears within the next few slots, and that over a window the schedule
|
|
// covers most of the set (real rotation, not a stuck single proposer).
|
|
distinct := map[ids.NodeID]struct{}{}
|
|
substituteWithin := -1
|
|
for slot := uint64(0); slot < uint64(numValidators)*3; slot++ {
|
|
p, err := w.ExpectedProposer(context.Background(), 13, 0, slot)
|
|
require.NoError(err)
|
|
distinct[p] = struct{}{}
|
|
if slot >= 1 && slot <= 5 && p != down && substituteWithin < 0 {
|
|
substituteWithin = int(slot)
|
|
}
|
|
}
|
|
require.GreaterOrEqual(substituteWithin, 1,
|
|
"no healthy substitute proposer was designated within 5 slots of the down leader %s — a faulty "+
|
|
"leader would stall the chain instead of being routed around", down)
|
|
require.Greater(len(distinct), numValidators/2,
|
|
"the schedule designated only %d of %d validators over the window — rotation too weak to route around "+
|
|
"faults", len(distinct), numValidators)
|
|
}
|