Files
node/chains/quorum_guard_node_test.go
zeekay 4c7bab464c node(chains): deliver the REAL P-chain epoch height to the chain engine (b2)
THE BUG. A K>1 quorum chain pins its weighted validator set to a P-chain epoch
height (set-root, 2/3-by-stake tally, per-voter pubkey resolution all read at that
one height). The engine reads that height off the VM block via pChainHeightOf,
asserting block.SignedBlock.PChainHeight() — but every plugin VM block (C-Chain
EVM, dexvm) exposes none, so pChainHeightOf returns 0 and the set resolves at
P-chain height 0: the GENESIS set. That is safe (non-empty, identical on every
node, <= current) and unbricks finality, but FREEZES the epoch at genesis: a
validator that JOINED post-genesis is absent from set@0 -> its vote is dropped and
its stake uncounted -> finality cannot track a DYNAMIC validator set, and a
departed genesis majority could collude.

THE FIX (Option b: rpcchainvm zap boundary, NOT a chain-creation-switch rewrite).
pChainHeightVM wraps the chain BlockBuilder so the block the engine sees carries
the proposer's live P-chain epoch height H = max(GetCurrentHeight, parentH),
WITHOUT changing the inner VM's block format, IDs, or ledger state:
  - BuildBlock stamps H and frames the gossiped bytes [magic|H|innerBytes];
  - ParseBlock splits the frame so every follower ADOPTS the identical H (never
    recomputes it from its own skewing P-chain view) -> determinism: every honest
    node derives the SAME epoch height from the SAME signed block, so the cert
    set-root they recompute matches and a post-genesis validator's vote+stake
    count. Raw (unframed) bytes parse with H=0 = the safe genesis fallback.
This is consensus-TRANSPORT framing, not a chain/ledger fork: inner bytes, block
IDs, and execution state are byte-identical -> no re-genesis, only a coordinated
node upgrade. Installed ONLY on K>1 chains (a K==1 chain has no cert/epoch).

Also wires the height-indexed P-Chain validators.State as the SINGLE source of
epoch truth for all four reads (verifier pubkey, stake, total stake, set-root),
all keyed on the block's P-chain height, and FAILS CLOSED if a K>1 chain is built
without a live height-indexed state (a silent permanent-stall wiring bug becomes a
loud refuse-to-start).

TDD (CGO_ENABLED=0, the canonical purego BLS path = ACTUAL production quorum
sources, not an ed25519 stand-in):
  - TestPChainHeightVM_DeliversRealHeight: pChainHeightOf(builtBlock)==H and
    ==H again after a ParseBlock round-trip of the gossiped bytes; a bare inner
    block reads 0 (pins why the wrapper is load-bearing).
  - TestPChainHeightVM_FinalizesAtGenesis: K>1 finalizes against set@0.
  - TestPChainHeightVM_FinalizesAfterStakingChange: validators that JOINED at
    epoch 7 cast the deciding 2/3 votes+stake and the block FINALIZES; the cert
    verifies stake-weighted at 7 and FAILS at 0; the production verifier rejects a
    joiner at height 0 but accepts it at 7. Proven to FAIL without the fix (stamp
    0 -> joiners dropped at set@0 -> VM.Accept never runs = permanent stall).
2026-06-22 22:21:55 -07:00

110 lines
4.8 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_guard_node_test.go — CRITICAL-1(c) fail-closed guard + the wiring proof
// the MEDIUM-1 round lacked. The round-1 tests injected a validators.State into
// the source constructors directly and never exercised the path where
// m.validatorState is nil (the production default until the P-Chain publishes
// its State). These tests pin:
//
// (1) the guard predicate: a K>1 chain with NO height-indexed state is refused
// (would otherwise stall finality forever);
// (2) the failure mechanism: getValidatorState(nil) → the no-op State, whose
// GetValidatorSet is EMPTY at every height → the stake source totals 0 and
// the set-root is Empty (exactly the inputs that make VerifyWeighted fail
// closed). This is what the guard exists to prevent silently;
// (3) the fix: once a REAL height-indexed state is published, getValidatorState
// returns it and the same sources read a live set (non-zero stake / non-Empty
// root) — finality can proceed.
package chains
import (
"context"
"testing"
"github.com/luxfi/ids"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
)
// TestQuorumGuard_RefusesNoopState pins the guard predicate (CRITICAL-1(c)): a
// nil (unpublished) validator state is NOT live, so a K>1 chain must refuse to
// start; a published state IS live.
func TestQuorumGuard_RefusesNoopState(t *testing.T) {
if quorumValidatorStateLive(nil) {
t.Fatal("CRITICAL-1(c): a nil validator state must NOT be considered live (would stall finality)")
}
live := validatorstest.NewTestState()
if !quorumValidatorStateLive(live) {
t.Fatal("a published height-indexed validator state must be considered live")
}
}
// TestGetValidatorState_NoopIsEmptyAtEveryHeight proves the failure mechanism the
// guard prevents: with no state published, getValidatorState yields the no-op
// State, and the height-pinned sources read an EMPTY set at every height — zero
// total stake and an Empty set-root, the exact inputs that make the engine's
// VerifyWeighted fail closed (ErrQCStakeBelowSupermajority) so NO block finalizes.
func TestGetValidatorState_NoopIsEmptyAtEveryHeight(t *testing.T) {
netID := ids.GenerateTestID()
// Production default: validatorState unset → getValidatorState(nil) = no-op.
noop := getValidatorState(nil)
if noop == nil {
t.Fatal("getValidatorState(nil) must return the no-op State, not nil")
}
stake := newValidatorStakeSource(noop, netID)
root := newValidatorSetRootSource(noop, netID)
for _, h := range []uint64{0, 1, 7, 1_000, 10_000_000} {
if total := stake.TotalStake(h); total != 0 {
t.Fatalf("no-op State must report zero total stake at height %d, got %d", h, total)
}
if r := root.ValidatorSetRoot(h); r != ids.Empty {
t.Fatalf("no-op State must commit to Empty set-root at height %d, got %s", h, r)
}
}
}
// TestGetValidatorState_LiveStateReadsSet proves the fix: once a real
// height-indexed state is published (as the P-Chain does), getValidatorState
// returns it and the SAME sources read a live set — non-zero stake and a
// non-Empty set-root — so the ⅔-by-stake finality predicate can be satisfied.
func TestGetValidatorState_LiveStateReadsSet(t *testing.T) {
netID := ids.GenerateTestID()
n0, n1, n2 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
const H = uint64(7)
live := validatorstest.NewTestState()
live.GetValidatorSetF = func(_ context.Context, height uint64, gotNet ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
if gotNet != netID || height != H {
return map[ids.NodeID]*validators.GetValidatorOutput{}, nil
}
return map[ids.NodeID]*validators.GetValidatorOutput{
n0: {NodeID: n0, PublicKey: []byte("pk0"), Light: 30, Weight: 30},
n1: {NodeID: n1, PublicKey: []byte("pk1"), Light: 30, Weight: 30},
n2: {NodeID: n2, PublicKey: []byte("pk2"), Light: 40, Weight: 40},
}, nil
}
// getValidatorState passes a non-nil state through unchanged.
got := getValidatorState(live)
stake := newValidatorStakeSource(got, netID)
root := newValidatorSetRootSource(got, netID)
if total := stake.TotalStake(H); total != 100 {
t.Fatalf("live State must report total stake 100 at the epoch, got %d", total)
}
if w := stake.Weight(n2, H); w != 40 {
t.Fatalf("live State must report n2 weight 40 at the epoch, got %d", w)
}
if r := root.ValidatorSetRoot(H); r == ids.Empty {
t.Fatal("live State must commit to a NON-Empty set-root at the epoch")
}
// A different height (no set) is still Empty/zero — the read is height-pinned.
if stake.TotalStake(H+1) != 0 || root.ValidatorSetRoot(H+1) != ids.Empty {
t.Fatal("live State read must be height-pinned (empty at a height with no set)")
}
}