mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
+17
-6
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user