node(chains): height-pin the quorum set-root + stake tally (MEDIUM-1)

MEDIUM-1 liveness regression: validatorSetRootSource.ValidatorSetRoot
ignored the height argument and hashed the Manager's CURRENT GetMap()
snapshot. During a validator-set change (P-chain / L1 staking), the
current map propagates ASYNC relative to a value-chain block-H vote, so
the signer and the assembler held different current maps -> different
set-roots -> the canonical SIGNED message differed -> signatures FAILED
verification -> votes dropped -> finality stalled at every staking change.
The epoch-binding (set-root in the signed message) made this bite.

FIX: read the HEIGHT-INDEXED set from validators.State.GetValidatorSet(
ctx, height, netID) — deterministic across nodes at a given value-chain
height, independent of async current-map skew. The engine already threads
the block height to ValidatorSetRoot(height) via the sole position builder
blockPositionLocked, so sign-side and verify-side read the SAME
height-pinned set.

The stake source (Weight/TotalStake) is pinned to the SAME height too, so
the tally is measured at the same epoch as the signed membership — a
validator whose vote is in a height-H cert contributes its height-H
weight, closing the second skew (a current-map weight read could drop a
legitimately-signed quorum). validatorSetAtHeight is the single shared
epoch read; hashValidatorSet is the single (byte-unchanged) set-root
encoding.

The vote verifier keeps the current-map pubkey lookup (a separate, milder,
self-healing axis — not MEDIUM-1; disclosed for review).

TDD:
  - TestValidatorSetRoot_CrossNodeAgreesDespiteSkew — TWO nodes with a
    set-change in flight (divergent current maps) compute the IDENTICAL
    set-root at height H (the missing cross-node test).
  - TestValidatorSetRoot_HeightSelectsEpoch — root is a deterministic
    function of height.
  - TestValidatorStakeSource_HeightPinned — tally read at the cert height.
  - TestHashValidatorSet_ByteStability — golden, wire format unchanged.
  - TestValidatorSetRoot_FailSoftIsUniform — Empty fallback is uniform.
This commit is contained in:
zeekay
2026-06-22 19:34:57 -07:00
parent 7e4a0c5b84
commit 576ab83224
3 changed files with 380 additions and 160 deletions
+17 -6
View File
@@ -1236,14 +1236,25 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
if consensusParams.K > 1 {
netCfg.VoteVerifier = newBLSVoteVerifier(m.Validators, networkID)
netCfg.VoteSigner = newBLSVoteSigner(m.StakingBLSKey)
// Height-indexed validator state is the SINGLE source of epoch truth for
// both the ⅔-by-stake tally and the set-root commitment (MEDIUM-1). It is
// read at the cert-position height so every node computes the IDENTICAL
// set, weights, and root for a given value-chain height — independent of
// async current-map skew during a P-chain / L1-staking change (reading the
// CURRENT map made the signer and the assembler disagree on the set-root
// and stall finality at every staking change). getValidatorState supplies a
// no-op State on non-staking nodes (Empty root / zero stake — the engine's
// unbound default).
vdrState := getValidatorState(m.validatorState)
// Stake-weighted finality (HIGH-3): require a ⅔-of-stake supermajority,
// not just the α-of-K count, so a low-stake coalition cannot finalize.
netCfg.StakeSource = newValidatorStakeSource(m.Validators, networkID)
// Epoch binding (MEDIUM): pin every vote/cert to the active weighted
// validator set so the ⅔-by-stake predicate is enforced at the
// cert-position epoch — a cert gathered under one set cannot be
// re-verified against another (its signatures were over this root).
netCfg.ValidatorSetRoot = newValidatorSetRootSource(m.Validators, networkID)
netCfg.StakeSource = newValidatorStakeSource(vdrState, networkID)
// Epoch binding (MEDIUM-1): pin every vote/cert to the weighted validator
// set IN FORCE AT the cert-position height so the ⅔-by-stake predicate is
// enforced at that epoch — a cert gathered under one set cannot be
// re-verified against another (its signatures were over this height-pinned
// root).
netCfg.ValidatorSetRoot = newValidatorSetRootSource(vdrState, networkID)
}
consensusEngine := consensuschain.NewRuntime(netCfg)
+102 -62
View File
@@ -24,6 +24,7 @@ package chains
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
@@ -124,58 +125,80 @@ func (s *blsVoteSigner) SignVote(message []byte) ([]byte, error) {
var _ consensuschain.VoteSigner = (*blsVoteSigner)(nil)
// --- stake source (HIGH-3) ---------------------------------------------------
// --- height-pinned epoch read (MEDIUM-1) -------------------------------------
// validatorSetAtHeight reads the validator set IN FORCE AT a value-chain height
// from the height-indexed validators.State. This is the SINGLE source of epoch
// truth shared by the stake source and the set-root source: both membership and
// weights are read at the SAME height H so a cert's signed set-root and its
// ⅔-by-stake tally are measured against the identical set.
//
// Determinism across nodes is the whole point. validators.State.GetValidatorSet
// returns the set the network already agreed on at height H (P-chain / L1-staking
// consensus), so every honest node computes the same set — and therefore the
// same set-root and the same tally — for a given H, INDEPENDENT of async
// current-map skew during a validator-set change. (The previous Manager.GetMap()
// read hashed the CURRENT map, which diverges between the signer and the
// assembler across that skew window → mismatched canonical messages → dropped
// votes → finality stall at every staking change. That was MEDIUM-1.)
//
// A nil state, a lookup error, or an empty set yields a nil map, which the
// callers fold into their fail-soft answers (0 weight / Empty root). An error is
// SYMMETRIC across nodes (a committed height H reads the same on every node, or
// fails the same way), so the degraded answer is uniform — it never makes one
// node's view disagree with another's, which is the property that matters here.
func validatorSetAtHeight(state validators.State, networkID ids.ID, height uint64) map[ids.NodeID]*validators.GetValidatorOutput {
if state == nil {
return nil
}
set, err := state.GetValidatorSet(context.Background(), height, networkID)
if err != nil || len(set) == 0 {
return nil
}
return set
}
// --- stake source (HIGH-3, height-pinned by MEDIUM-1) ------------------------
// validatorStakeSource supplies validator voting weights so the engine can
// require a ⅔-by-stake supermajority for finality (HIGH-3). Weights are read
// from the chain's validator set via the node validator Manager.
//
// EPOCH MODEL (the MEDIUM fix). The node Manager is SINGLE-EPOCH: it exposes the
// CURRENT active set, not a height-indexed history (validators.Manager has no
// GetValidatorSet(height); that lives on validators.State). So this source
// honestly returns CURRENT-epoch weights, which is exactly right for the LIVE
// finality path — a cert is verified in the SAME epoch it is created, so
// "current set" == "the set at the cert's height". The height argument is
// therefore not used to time-travel weights here.
//
// What makes the ⅔-by-stake predicate SOUND ACROSS epochs (so an old cert
// cannot be re-judged under a different epoch's stake) is NOT this source
// guessing historical weights — it is the engine binding the active weighted-set
// commitment into every signed vote (validatorSetRootSource below →
// VotePosition.ValidatorSetRoot → CanonicalVoteMessage). A cert is
// cryptographically pinned to the set it was certified under: re-presenting it
// under a different epoch's set-root fails signature verification. Thus the
// current-epoch read here is the live-path answer, and cross-epoch laundering is
// closed at the witness layer, not papered over with an unavailable history.
// from the HEIGHT-INDEXED validators.State at the cert-position height, the same
// height the set-root commits to (MEDIUM-1). Reading the tally at the same epoch
// as the signed membership means a validator whose vote is in the cert (its
// signature verifies against the height-H set-root) also contributes its height-H
// weight to the tally — eliminating the second skew (a current-map weight read
// could drop a legitimately-signed quorum when membership changed between sign
// and tally).
type validatorStakeSource struct {
vdrs validators.Manager
state validators.State
networkID ids.ID
}
func newValidatorStakeSource(vdrs validators.Manager, networkID ids.ID) *validatorStakeSource {
return &validatorStakeSource{vdrs: vdrs, networkID: networkID}
func newValidatorStakeSource(state validators.State, networkID ids.ID) *validatorStakeSource {
return &validatorStakeSource{state: state, networkID: networkID}
}
// Weight implements consensuschain.StakeSource. Returns the validator's
// current-epoch stake (see the epoch-model note on the type); height selects the
// epoch only on a chain with a height-indexed source, which the single-epoch
// node Manager is not.
func (s *validatorStakeSource) Weight(nodeID ids.NodeID, _ uint64) uint64 {
if s.vdrs == nil {
// Weight implements consensuschain.StakeSource. Returns the validator's stake in
// the set IN FORCE AT height — deterministic across nodes for a given height. An
// unknown validator (or a fail-soft empty read) yields 0, which cannot inflate
// the numerator.
func (s *validatorStakeSource) Weight(nodeID ids.NodeID, height uint64) uint64 {
out, ok := validatorSetAtHeight(s.state, s.networkID, height)[nodeID]
if !ok || out == nil {
return 0
}
return s.vdrs.GetLight(s.networkID, nodeID)
return out.Light
}
// TotalStake implements consensuschain.StakeSource. Current-epoch total active
// stake (see the epoch-model note on the type).
func (s *validatorStakeSource) TotalStake(_ uint64) uint64 {
if s.vdrs == nil {
return 0
}
total, err := s.vdrs.TotalLight(s.networkID)
if err != nil {
return 0
// TotalStake implements consensuschain.StakeSource. Total active stake of the set
// IN FORCE AT height (the denominator of the ⅔ predicate), measured at the same
// epoch as Weight and the set-root.
func (s *validatorStakeSource) TotalStake(height uint64) uint64 {
var total uint64
for _, out := range validatorSetAtHeight(s.state, s.networkID, height) {
if out != nil {
total += out.Light
}
}
return total
}
@@ -184,40 +207,59 @@ var _ consensuschain.StakeSource = (*validatorStakeSource)(nil)
// --- validator-set-root source (MEDIUM: epoch binding) -----------------------
// validatorSetRootSource computes the deterministic commitment to the chain's
// active weighted validator set — the value the engine stamps into every vote's
// VotePosition.ValidatorSetRoot so a cert is pinned to the exact set it was
// certified under. It is the node side of the MEDIUM fix: it turns the
// validatorSetRootSource computes the deterministic commitment to the validator
// set IN FORCE AT a value-chain height — the value the engine stamps into every
// vote's VotePosition.ValidatorSetRoot so a cert is pinned to the exact set it
// was certified under. It is the node side of the MEDIUM fix: it turns the
// "⅔-by-stake measured at the cert-position epoch" property into an ENFORCED
// invariant (a cross-epoch cert fails verification because every signature was
// over this root).
//
// HEIGHT-PINNED (MEDIUM-1). The root is read from the HEIGHT-INDEXED
// validators.State at the value-chain block height, NOT from the Manager's
// CURRENT map. At a given height H, GetValidatorSet returns the set the network
// already agreed on, so every honest node — signer and assembler alike — computes
// the IDENTICAL root for H, independent of async current-map skew during a
// validator-set change. Reading the current map (the prior bug) let the signer
// and the assembler hold different maps across that skew window → different roots
// → the canonical signed message differed → signatures failed verification →
// votes dropped → finality stalled at every staking change.
//
// The commitment is a SHA-256 over the set serialized in a canonical order
// (validators sorted by NodeID, each as nodeID || light || len(pubkey) ||
// pubkey). Determinism is essential: every honest node computing the root for
// the same active set MUST agree, or their signatures over the same block would
// not be mutually verifiable. Sorting by NodeID + length-prefixing the pubkey
// makes the encoding canonical and unambiguous.
// pubkey) — see hashValidatorSet. Sorting by NodeID + length-prefixing the
// pubkey makes the encoding canonical and unambiguous; the byte layout is
// UNCHANGED from the prior implementation, so the wire format and the engine's
// epoch-binding contract are preserved (only the SOURCE of the set changed from
// the current map to the height-indexed set).
type validatorSetRootSource struct {
vdrs validators.Manager
state validators.State
networkID ids.ID
}
func newValidatorSetRootSource(vdrs validators.Manager, networkID ids.ID) *validatorSetRootSource {
return &validatorSetRootSource{vdrs: vdrs, networkID: networkID}
func newValidatorSetRootSource(state validators.State, networkID ids.ID) *validatorSetRootSource {
return &validatorSetRootSource{state: state, networkID: networkID}
}
// ValidatorSetRoot implements consensuschain.ValidatorSetRootSource. Returns the
// commitment to the CURRENT active weighted set (the single-epoch node Manager
// has no per-height history; the live finality path commits to the set in force
// when the vote is cast, which is what pins the cert to its epoch). Returns
// ids.Empty when the manager is absent or the set is empty (an empty commitment
// is the explicit "unbound" answer, consistent with the engine default).
func (s *validatorSetRootSource) ValidatorSetRoot(_ uint64) ids.ID {
if s.vdrs == nil {
return ids.Empty
}
set := s.vdrs.GetMap(s.networkID)
// commitment to the weighted set IN FORCE AT height (deterministic across nodes).
// Returns ids.Empty when the state is absent or the set is empty (the explicit
// "unbound" answer, consistent with the engine default); a height-read error is
// symmetric across nodes, so the Empty fallback is uniform and never creates a
// cross-node root disagreement.
func (s *validatorSetRootSource) ValidatorSetRoot(height uint64) ids.ID {
return hashValidatorSet(validatorSetAtHeight(s.state, s.networkID, height))
}
var _ consensuschain.ValidatorSetRootSource = (*validatorSetRootSource)(nil)
// hashValidatorSet computes the canonical SHA-256 commitment to a weighted
// validator set: validators sorted by NodeID, each serialized as
// nodeID || light(8,BE) || len(pubkey)(8,BE) || pubkey. An empty/nil set commits
// to ids.Empty (the "unbound" answer). This is the SINGLE definition of the
// set-root encoding (DRY) — both the live source and its tests hash through here,
// so the wire format cannot drift between them.
func hashValidatorSet(set map[ids.NodeID]*validators.GetValidatorOutput) ids.ID {
if len(set) == 0 {
return ids.Empty
}
@@ -244,8 +286,6 @@ func (s *validatorSetRootSource) ValidatorSetRoot(_ uint64) ids.ID {
return root
}
var _ consensuschain.ValidatorSetRootSource = (*validatorSetRootSource)(nil)
// --- app-gossip envelope for votes/certs -------------------------------------
// The QuorumGossiper transport rides on app-gossip. A single framed envelope
+261 -92
View File
@@ -1,124 +1,293 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_setroot_test.go — node-layer tests for the MEDIUM fix: the node must
// (a) supply a deterministic commitment to the active weighted validator set so
// the engine can pin every cert to its epoch, and (b) supply CURRENT-epoch stake
// weights honestly (the single-epoch node Manager has no per-height history; the
// cross-epoch soundness is enforced by the set-root binding, not by guessing
// stake at past heights).
// quorum_setroot_test.go — node-layer tests for MEDIUM-1: the set-root and the
// ⅔-by-stake tally MUST be read from the HEIGHT-INDEXED validators.State at the
// cert-position height, so every node computes the IDENTICAL root, weights, and
// total for a given value-chain height — INDEPENDENT of async current-map skew
// during a validator-set change.
//
// The cross-node test (TestValidatorSetRoot_CrossNodeAgreesDespiteSkew) is the
// one the red flagged as MISSING: it models two nodes whose CURRENT validator
// views diverge (a set-change in flight) but who agree on the historical set at a
// pinned height H — and proves they nonetheless compute the SAME set-root at H.
// Reading the current map (the prior bug) would have made their roots differ →
// mismatched canonical signed messages → dropped votes → finality stall.
package chains
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"sort"
"testing"
"github.com/luxfi/ids"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
)
// TestValidatorSetRootSource_DeterministicAndSetSensitive proves the set-root is
// (1) deterministic for a fixed set (so honest nodes agree, a precondition for
// mutually-verifiable signatures), (2) order-independent of insertion (the
// commitment sorts by NodeID), and (3) CHANGES when the set or a weight changes
// (so a different epoch yields a different root → a cross-epoch cert is pinned
// out). Empty/absent sets commit to ids.Empty (the "unbound" answer).
func TestValidatorSetRootSource_DeterministicAndSetSensitive(t *testing.T) {
// expectedSetRoot is an INDEPENDENT reimplementation of the canonical set-root
// spec, used only by the golden test to cross-check hashValidatorSet. It is
// deliberately NOT a call to hashValidatorSet (that would be a tautology): if the
// production encoding ever diverges from this spec the golden test fails.
func expectedSetRoot(t *testing.T, vdrs []vdr) ids.ID {
t.Helper()
sort.Slice(vdrs, func(i, j int) bool {
return bytes.Compare(vdrs[i].nodeID[:], vdrs[j].nodeID[:]) < 0
})
h := sha256.New()
var u64 [8]byte
for _, v := range vdrs {
h.Write(v.nodeID[:])
binary.BigEndian.PutUint64(u64[:], v.light)
h.Write(u64[:])
binary.BigEndian.PutUint64(u64[:], uint64(len(v.pk)))
h.Write(u64[:])
h.Write(v.pk)
}
var root ids.ID
copy(root[:], h.Sum(nil))
return root
}
// vdr is a tiny height→set fixture: which validators (and weights/keys) the
// height-indexed state reports at each value-chain height.
type vdr struct {
nodeID ids.NodeID
pk []byte
light uint64
}
// stateWithHistory builds a validators.State whose GetValidatorSet returns the
// set registered for the requested height (and an empty set for unknown heights),
// scoped to netID. This is the height-indexed source MEDIUM-1 reads from.
func stateWithHistory(netID ids.ID, byHeight map[uint64][]vdr) *validatorstest.TestState {
s := validatorstest.NewTestState()
s.GetValidatorSetF = func(_ context.Context, height uint64, gotNet ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
if gotNet != netID {
return map[ids.NodeID]*validators.GetValidatorOutput{}, nil
}
out := make(map[ids.NodeID]*validators.GetValidatorOutput)
for _, v := range byHeight[height] {
out[v.nodeID] = &validators.GetValidatorOutput{
NodeID: v.nodeID,
PublicKey: v.pk,
Light: v.light,
Weight: v.light,
}
}
return out, nil
}
return s
}
// TestValidatorSetRoot_CrossNodeAgreesDespiteSkew is the MEDIUM-1 regression
// guard. Two nodes are mid validator-set change: their CURRENT sets differ
// (height 11 vs height 12), but both still serve the SAME committed set at the
// value-chain block height H=10 being voted on. The set-root at H MUST be
// identical on both nodes — that identity is what makes their signatures over the
// block's canonical message mutually verifiable. The prior current-map read would
// have produced two different roots here and stalled finality.
func TestValidatorSetRoot_CrossNodeAgreesDespiteSkew(t *testing.T) {
netID := ids.GenerateTestID()
n0, n1, n2, n3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
pk0, pk1, pk2, pk3 := []byte("pk-0-48bytes-placeholder"), []byte("pk-1"), []byte("pk-2"), []byte("pk-3")
const H = uint64(10)
setAtH := []vdr{{n0, pk0, 10}, {n1, pk1, 20}, {n2, pk2, 30}}
// Node A has already applied a stake bump that took effect at height 11
// (n1: 20→25) and sees that as its current set. It still knows the height-10
// set (the block being voted on).
nodeA := stateWithHistory(netID, map[uint64][]vdr{
H: setAtH,
11: {{n0, pk0, 10}, {n1, pk1, 25}, {n2, pk2, 30}},
})
// Node B has already applied a NEW validator that joined at height 12
// (n3 added) — a different, later current set. It too still knows height 10.
nodeB := stateWithHistory(netID, map[uint64][]vdr{
H: setAtH,
12: {{n0, pk0, 10}, {n1, pk1, 25}, {n2, pk2, 30}, {n3, pk3, 5}},
})
rootA := newValidatorSetRootSource(nodeA, netID).ValidatorSetRoot(H)
rootB := newValidatorSetRootSource(nodeB, netID).ValidatorSetRoot(H)
if rootA == ids.Empty {
t.Fatal("set-root at the voted height must be non-Empty (the set is non-empty)")
}
if rootA != rootB {
t.Fatalf("MEDIUM-1: cross-node set-root at height %d MUST be identical despite "+
"current-set skew, got A=%s B=%s", H, rootA, rootB)
}
// Sanity: had either node (wrongly) committed to its CURRENT set instead of
// the height-H set, the roots WOULD differ — proving the test actually
// exercises the skew (not a vacuous match).
rootA_cur := newValidatorSetRootSource(nodeA, netID).ValidatorSetRoot(11)
rootB_cur := newValidatorSetRootSource(nodeB, netID).ValidatorSetRoot(12)
if rootA_cur == rootB_cur {
t.Fatal("test is vacuous: the two nodes' CURRENT sets must differ to exercise the skew")
}
if rootA == rootA_cur {
t.Fatal("test is vacuous: height-H set must differ from node A's current set")
}
}
// TestValidatorSetRoot_HeightSelectsEpoch proves the root is a deterministic
// FUNCTION OF HEIGHT (not a fixed current-set snapshot): different heights with
// different sets yield different roots, the same height always yields the same
// root, and the encoding is insertion-order independent (canonical sort).
func TestValidatorSetRoot_HeightSelectsEpoch(t *testing.T) {
netID := ids.GenerateTestID()
n0, n1, n2 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
pk0, pk1, pk2 := []byte("pk-0-48bytes-placeholder"), []byte("pk-1"), []byte("pk-2")
// Empty manager → Empty root.
empty := newValidatorSetRootSource(validators.NewManager(), netID)
if got := empty.ValidatorSetRoot(0); got != ids.Empty {
t.Fatalf("empty set must commit to ids.Empty, got %s", got)
state := stateWithHistory(netID, map[uint64][]vdr{
10: {{n0, pk0, 10}, {n1, pk1, 20}, {n2, pk2, 30}},
11: {{n0, pk0, 10}, {n1, pk1, 21}, {n2, pk2, 30}}, // n1 weight changed
})
src := newValidatorSetRootSource(state, netID)
root10 := src.ValidatorSetRoot(10)
root11 := src.ValidatorSetRoot(11)
if root10 == ids.Empty || root11 == ids.Empty {
t.Fatal("non-empty sets must commit to non-Empty roots")
}
if root10 == root11 {
t.Fatal("a different epoch (height with a changed weight) MUST yield a different root")
}
// Same height is stable (deterministic), repeatedly.
if again := src.ValidatorSetRoot(10); again != root10 {
t.Fatalf("set-root at a fixed height must be deterministic: %s != %s", again, root10)
}
// nil manager → Empty root (fail-soft).
if got := (&validatorSetRootSource{vdrs: nil, networkID: netID}).ValidatorSetRoot(0); got != ids.Empty {
t.Fatalf("nil manager must commit to ids.Empty, got %s", got)
}
// Build set {n0:10, n1:20, n2:30} in one order.
mgrA := validators.NewManager()
mustAddStaker(t, mgrA, netID, n0, pk0, 10)
mustAddStaker(t, mgrA, netID, n1, pk1, 20)
mustAddStaker(t, mgrA, netID, n2, pk2, 30)
rootA := newValidatorSetRootSource(mgrA, netID).ValidatorSetRoot(0)
if rootA == ids.Empty {
t.Fatal("non-empty set must commit to a non-Empty root")
}
// Same set, DIFFERENT insertion order → SAME root (sorted commitment).
mgrB := validators.NewManager()
mustAddStaker(t, mgrB, netID, n2, pk2, 30)
mustAddStaker(t, mgrB, netID, n0, pk0, 10)
mustAddStaker(t, mgrB, netID, n1, pk1, 20)
rootB := newValidatorSetRootSource(mgrB, netID).ValidatorSetRoot(0)
if rootA != rootB {
t.Fatalf("set-root must be insertion-order independent: %s != %s", rootA, rootB)
}
// The height argument does not change the current-epoch commitment.
if h := newValidatorSetRootSource(mgrB, netID).ValidatorSetRoot(999_999); h != rootA {
t.Fatalf("set-root must be stable across the (advisory) height arg: %s != %s", h, rootA)
}
// Change ONE weight → DIFFERENT root (a new epoch is a different set).
mgrC := validators.NewManager()
mustAddStaker(t, mgrC, netID, n0, pk0, 10)
mustAddStaker(t, mgrC, netID, n1, pk1, 21) // 20 -> 21
mustAddStaker(t, mgrC, netID, n2, pk2, 30)
rootC := newValidatorSetRootSource(mgrC, netID).ValidatorSetRoot(0)
if rootC == rootA {
t.Fatal("a weight change MUST change the set-root (else a cert is not pinned to its epoch's stake)")
}
// Add a validator → DIFFERENT root.
mustAddStaker(t, mgrC, netID, ids.GenerateTestNodeID(), []byte("pk-3"), 5)
if rootD := newValidatorSetRootSource(mgrC, netID).ValidatorSetRoot(0); rootD == rootC {
t.Fatal("adding a validator MUST change the set-root")
}
// A different network's set is independent (scoped by networkID).
if other := newValidatorSetRootSource(mgrA, ids.GenerateTestID()).ValidatorSetRoot(0); other != ids.Empty {
t.Fatalf("a network with no validators must commit to ids.Empty, got %s", other)
// Insertion-order independence: a state that lists the SAME height-10 members
// in a different slice order yields the SAME root (canonical NodeID sort).
reordered := stateWithHistory(netID, map[uint64][]vdr{
10: {{n2, pk2, 30}, {n0, pk0, 10}, {n1, pk1, 20}},
})
if r := newValidatorSetRootSource(reordered, netID).ValidatorSetRoot(10); r != root10 {
t.Fatalf("set-root must be member-order independent: %s != %s", r, root10)
}
}
// TestValidatorStakeSource_CurrentEpochWeights proves the stake source reports
// the current set's weights and total honestly (the live in-epoch path), and is
// fail-soft (nil manager → 0).
func TestValidatorStakeSource_CurrentEpochWeights(t *testing.T) {
// TestValidatorSetRoot_FailSoftIsUniform proves the fail-soft answers are
// Empty/uniform (never a panic, never a per-node-divergent default): a nil state,
// an unknown height (empty set), an unknown network, and a height-read error all
// commit to ids.Empty. Uniformity is the safety property — a symmetric error
// degrades every node to the same Empty root, never to disagreeing roots.
func TestValidatorSetRoot_FailSoftIsUniform(t *testing.T) {
netID := ids.GenerateTestID()
n0, n1 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
mgr := validators.NewManager()
mustAddStaker(t, mgr, netID, n0, []byte("pk0"), 70)
mustAddStaker(t, mgr, netID, n1, []byte("pk1"), 30)
src := newValidatorStakeSource(mgr, netID)
if w := src.Weight(n0, 0); w != 70 {
t.Fatalf("Weight(n0) = %d, want 70", w)
// nil state → Empty.
if got := (&validatorSetRootSource{state: nil, networkID: netID}).ValidatorSetRoot(10); got != ids.Empty {
t.Fatalf("nil state must commit to ids.Empty, got %s", got)
}
if w := src.Weight(n1, 12345); w != 30 { // height arg is advisory for the single-epoch manager
t.Fatalf("Weight(n1) = %d, want 30", w)
// Known network, but a height with no registered set → Empty.
state := stateWithHistory(netID, map[uint64][]vdr{
10: {{ids.GenerateTestNodeID(), []byte("pk"), 10}},
})
if got := newValidatorSetRootSource(state, netID).ValidatorSetRoot(999); got != ids.Empty {
t.Fatalf("unknown height must commit to ids.Empty, got %s", got)
}
if total := src.TotalStake(0); total != 100 {
t.Fatalf("TotalStake = %d, want 100", total)
// Wrong network → empty set → Empty.
if got := newValidatorSetRootSource(state, ids.GenerateTestID()).ValidatorSetRoot(10); got != ids.Empty {
t.Fatalf("unknown network must commit to ids.Empty, got %s", got)
}
// Unknown node → 0 (cannot inflate the numerator).
if w := src.Weight(ids.GenerateTestNodeID(), 0); w != 0 {
t.Fatalf("Weight(unknown) = %d, want 0", w)
// A height-read ERROR → Empty (and it is symmetric: the same error on every
// node yields the same Empty root).
errState := validatorstest.NewTestState()
errState.GetValidatorSetF = func(_ context.Context, _ uint64, _ ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
return nil, errors.New("state unavailable at height")
}
// nil manager → fail-soft zeros.
nilSrc := &validatorStakeSource{vdrs: nil, networkID: netID}
if nilSrc.Weight(n0, 0) != 0 || nilSrc.TotalStake(0) != 0 {
t.Fatal("nil manager must yield zero weight and total")
if got := newValidatorSetRootSource(errState, netID).ValidatorSetRoot(10); got != ids.Empty {
t.Fatalf("a height-read error must commit to ids.Empty, got %s", got)
}
}
func mustAddStaker(t *testing.T, m validators.Manager, netID ids.ID, nodeID ids.NodeID, pk []byte, light uint64) {
t.Helper()
if err := m.AddStaker(netID, nodeID, pk, ids.GenerateTestID(), light); err != nil {
t.Fatalf("AddStaker: %v", err)
// TestValidatorStakeSource_HeightPinned proves the ⅔-by-stake tally is read at
// the SAME height as the set-root (MEDIUM-1's second skew): Weight/TotalStake are
// a deterministic function of height, so a validator whose vote is in a
// height-H cert contributes its height-H weight — not whatever the current map
// happens to hold after a membership change. This is what stops a current-map
// weight read from dropping a legitimately-signed quorum.
func TestValidatorStakeSource_HeightPinned(t *testing.T) {
netID := ids.GenerateTestID()
n0, n1, n2 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
state := stateWithHistory(netID, map[uint64][]vdr{
10: {{n0, []byte("pk0"), 70}, {n1, []byte("pk1"), 30}}, // total 100
11: {{n0, []byte("pk0"), 70}, {n1, []byte("pk1"), 30}, {n2, []byte("pk2"), 50}}, // total 150
})
src := newValidatorStakeSource(state, netID)
// At height 10 the tally is the height-10 epoch.
if w := src.Weight(n0, 10); w != 70 {
t.Fatalf("Weight(n0, h=10) = %d, want 70", w)
}
if total := src.TotalStake(10); total != 100 {
t.Fatalf("TotalStake(h=10) = %d, want 100", total)
}
// n2 is NOT in the height-10 set → 0 at h=10, but 50 at h=11. The tally is
// height-pinned, not current-map.
if w := src.Weight(n2, 10); w != 0 {
t.Fatalf("Weight(n2, h=10) = %d, want 0 (n2 joined at h=11)", w)
}
if w := src.Weight(n2, 11); w != 50 {
t.Fatalf("Weight(n2, h=11) = %d, want 50", w)
}
if total := src.TotalStake(11); total != 150 {
t.Fatalf("TotalStake(h=11) = %d, want 150", total)
}
// Unknown node at a known height → 0 (cannot inflate the numerator).
if w := src.Weight(ids.GenerateTestNodeID(), 10); w != 0 {
t.Fatalf("Weight(unknown, h=10) = %d, want 0", w)
}
// nil state → fail-soft zeros.
nilSrc := &validatorStakeSource{state: nil, networkID: netID}
if nilSrc.Weight(n0, 10) != 0 || nilSrc.TotalStake(10) != 0 {
t.Fatal("nil state must yield zero weight and total")
}
}
// TestHashValidatorSet_ByteStability is a GOLDEN test pinning the canonical
// set-root encoding so the wire format cannot drift (the engine's epoch-binding
// contract and any persisted/gossiped cert depend on this exact byte layout). If
// this value changes, the set-root encoding changed and every node in the
// network must upgrade in lockstep — it is a CONSENSUS-BREAKING change.
func TestHashValidatorSet_ByteStability(t *testing.T) {
// Fixed (non-random) NodeIDs so the golden is reproducible.
var a, b ids.NodeID
a[0], b[0] = 0x01, 0x02
set := map[ids.NodeID]*validators.GetValidatorOutput{
a: {NodeID: a, PublicKey: []byte{0xaa, 0xbb}, Light: 10},
b: {NodeID: b, PublicKey: []byte{0xcc}, Light: 20},
}
got := hashValidatorSet(set)
// Independently recompute the expected commitment from the canonical spec:
// sorted-by-NodeID, each nodeID || light(8,BE) || len(pk)(8,BE) || pk, SHA-256.
want := expectedSetRoot(t, []vdr{
{a, []byte{0xaa, 0xbb}, 10},
{b, []byte{0xcc}, 20},
})
if got != want {
t.Fatalf("set-root encoding drifted (CONSENSUS-BREAKING):\n got %s\n want %s", got, want)
}
// Empty/nil set → ids.Empty.
if hashValidatorSet(nil) != ids.Empty {
t.Fatal("nil set must commit to ids.Empty")
}
if hashValidatorSet(map[ids.NodeID]*validators.GetValidatorOutput{}) != ids.Empty {
t.Fatal("empty set must commit to ids.Empty")
}
}